feat: launch readiness — PWA, API hardening, memory leak fixes, sticker removal
- Add PWA infrastructure: vite-plugin-pwa, manifest, service worker, SW update prompt component, placeholder icons, Apple meta tags - Harden API client: 401 auto-logout, AbortController timeouts (30s standard, 120s uploads), onUnauthorized callback - Fix memory leaks: clear voice user status on leave, clean up all Maps (channelToSpaceMap, permissions, etc.) on removeSpace - Upgrade error boundary to Aether Drift design with Try Again button, collapsible stack trace, and componentDidCatch logging - Configure desktop icon paths in electron-builder.yml - Remove sticker feature (server routes, schema, types, UI components) - Fix Docker build: use **/node_modules in .dockerignore to prevent COPY from clobbering pnpm-installed workspace dependencies - Add vite-env.d.ts declarations for noise suppressor wasm imports - Exclude test files from tsc build via tsconfig
This commit is contained in:
@@ -28,7 +28,7 @@ export function EmojiPicker({ onEmojiSelect }: EmojiPickerProps) {
|
||||
skinTonePosition="search"
|
||||
previewPosition="none"
|
||||
navPosition="bottom"
|
||||
perLine={9}
|
||||
perLine={10}
|
||||
maxFrequentRows={2}
|
||||
emojiSize={24}
|
||||
emojiButtonSize={32}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MentionPopover } from './MentionPopover';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import { InputPopover, type InputPopoverTab } from './InputPopover';
|
||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||
import { MAX_MESSAGE_LENGTH, type MemberWithUser, type Sticker } from '@backspace/shared';
|
||||
import { MAX_MESSAGE_LENGTH, type MemberWithUser } from '@backspace/shared';
|
||||
import { useSettingsStore } from '../../stores/settingsStore';
|
||||
|
||||
interface MessageInputProps {
|
||||
@@ -31,7 +31,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
const inputContainerRef = useRef<HTMLDivElement>(null);
|
||||
const popoverAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const sendMessage = useChatStore((s) => s.sendMessage);
|
||||
const sendStickerMessage = useChatStore((s) => s.sendStickerMessage);
|
||||
const replyTo = useChatStore((s) => s.replyTo);
|
||||
const setReplyTo = useChatStore((s) => s.setReplyTo);
|
||||
const members = useSpaceStore((s) => s.members);
|
||||
@@ -39,8 +38,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
|
||||
// Feature flags
|
||||
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const stickersEnabled = spaces.length > 0; // stickers available if user is in any space
|
||||
|
||||
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
||||
const channelPerms = useSpaceStore((s) => s.channelPermissions.get(channelId));
|
||||
@@ -284,11 +281,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
sendMessage(channelId, url);
|
||||
}, [channelId, sendMessage]);
|
||||
|
||||
const handleStickerSelect = useCallback((sticker: Sticker) => {
|
||||
setActivePopover(null);
|
||||
sendStickerMessage(channelId, sticker.id);
|
||||
}, [channelId, sendStickerMessage]);
|
||||
|
||||
const togglePopover = useCallback((tab: InputPopoverTab) => {
|
||||
setActivePopover((prev) => prev === tab ? null : tab);
|
||||
}, []);
|
||||
@@ -309,17 +301,15 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
<div ref={popoverAnchorRef} data-pip-obstacle="bottom" className="relative px-3 pb-3 flex-shrink-0 md:absolute md:bottom-3 md:left-3 md:right-3 md:z-[110] md:px-0 md:pb-0 md:glass-bubble md:rounded-[14px]">
|
||||
<TypingIndicator channelId={channelId} />
|
||||
|
||||
{/* Input popover (emoji / gif / stickers) */}
|
||||
{/* Input popover (emoji / gif) */}
|
||||
{activePopover && (
|
||||
<InputPopover
|
||||
activeTab={activePopover}
|
||||
onClose={() => setActivePopover(null)}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
onGifSelect={handleGifSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
anchorRef={popoverAnchorRef}
|
||||
gifEnabled={gifEnabled}
|
||||
stickersEnabled={stickersEnabled}
|
||||
onTabChange={setActivePopover}
|
||||
/>
|
||||
)}
|
||||
@@ -460,19 +450,6 @@ export function MessageInput({ channelId, channelName }: MessageInputProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Sticker button */}
|
||||
<button
|
||||
onClick={() => togglePopover('stickers')}
|
||||
className={`w-[34px] h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
||||
activePopover === 'stickers' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||
}`}
|
||||
title="Stickers"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.5 2C6.81 2 2 6.81 2 12.5S6.81 23 12.5 23c1.31 0 2.56-.25 3.73-.7l5.07-5.07c.45-1.17.7-2.42.7-3.73C22 7.81 17.19 2 12.5 2Zm0 19c-4.69 0-8.5-3.81-8.5-8.5S7.81 4 12.5 4 21 7.81 21 12.5c0 .89-.14 1.74-.4 2.54l-3.56 3.56c-.8.26-1.65.4-2.54.4ZM8 11.5c.83 0 1.5-.67 1.5-1.5S8.83 8.5 8 8.5 6.5 9.17 6.5 10s.67 1.5 1.5 1.5Zm6 0c.83 0 1.5-.67 1.5-1.5s-.67-1.5-1.5-1.5-1.5.67-1.5 1.5.67 1.5 1.5 1.5Zm-1 3.5c-2.33 0-4.31-1.46-5.11-3.5h10.22c-.8 2.04-2.78 3.5-5.11 3.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Emoji button */}
|
||||
<button
|
||||
onClick={() => togglePopover('emoji')}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Sticker, StickerPack } from '@backspace/shared';
|
||||
|
||||
interface StickerPickerProps {
|
||||
onStickerSelect: (sticker: Sticker) => void;
|
||||
}
|
||||
|
||||
interface StickerCache {
|
||||
packs: StickerPack[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
let stickerCache: StickerCache | null = null;
|
||||
const CACHE_TTL = 60_000; // 60s
|
||||
|
||||
export function StickerPicker({ onStickerSelect }: StickerPickerProps) {
|
||||
const [packs, setPacks] = useState<StickerPack[]>(stickerCache?.packs ?? []);
|
||||
const [loading, setLoading] = useState(!stickerCache || Date.now() - stickerCache.fetchedAt > CACHE_TTL);
|
||||
const [query, setQuery] = useState('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (stickerCache && Date.now() - stickerCache.fetchedAt <= CACHE_TTL) {
|
||||
setPacks(stickerCache.packs);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api.stickers.myStickers()
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
stickerCache = { packs: data.packs, fetchedAt: Date.now() };
|
||||
setPacks(data.packs);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const filteredPacks = query.trim()
|
||||
? packs
|
||||
.map((pack) => ({
|
||||
...pack,
|
||||
stickers: pack.stickers.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
s.tags.toLowerCase().includes(query.toLowerCase()),
|
||||
),
|
||||
}))
|
||||
.filter((pack) => pack.stickers.length > 0)
|
||||
: packs;
|
||||
|
||||
const totalStickers = packs.reduce((sum, p) => sum + p.stickers.length, 0);
|
||||
|
||||
// Prevent keyboard events from bubbling
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const getStickerUrl = useCallback((sticker: Sticker) => {
|
||||
const filename = sticker.filename;
|
||||
if (filename.startsWith('http') || filename.startsWith('/')) return filename;
|
||||
return `/api/uploads/${filename}`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[390px]" onKeyDown={handleKeyDown}>
|
||||
{/* Search */}
|
||||
<div className="px-3 pt-2 pb-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search stickers"
|
||||
className="input-search w-full"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-4 gap-2 p-1">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square bg-surface-elevated rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : totalStickers === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<div className="text-txt-tertiary text-sm mb-1">No stickers available</div>
|
||||
<div className="text-txt-tertiary text-xs">
|
||||
Space admins can add sticker packs in Space Settings.
|
||||
</div>
|
||||
</div>
|
||||
) : filteredPacks.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm">
|
||||
No stickers matching "{query}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredPacks.map((pack) => (
|
||||
<div key={pack.id}>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider px-1 mb-1.5">
|
||||
{pack.name}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{pack.stickers.map((sticker) => (
|
||||
<button
|
||||
key={sticker.id}
|
||||
onClick={() => onStickerSelect(sticker)}
|
||||
className="aspect-square rounded-lg overflow-hidden hover:bg-interactive-hover transition-colors p-1.5 group"
|
||||
title={sticker.name}
|
||||
>
|
||||
<img
|
||||
src={getStickerUrl(sticker)}
|
||||
alt={sticker.name}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Invalidate the sticker cache (called when WS events indicate sticker changes) */
|
||||
export function invalidateStickerCache(): void {
|
||||
stickerCache = null;
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||
import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
||||
import { StickersPanel } from './spaceSettingsPanels/StickersPanel';
|
||||
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
|
||||
|
||||
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
|
||||
@@ -271,7 +270,7 @@ export function SpaceSettingsModal() {
|
||||
const spaces = useSpaceStore((s) => s.spaces);
|
||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
||||
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'stickers' | 'members' | 'roles' | 'bans'>('overview');
|
||||
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
|
||||
|
||||
const isOpen = activeModal === 'spaceSettings';
|
||||
const space = spaces.find(s => s.id === currentSpaceId);
|
||||
@@ -301,11 +300,6 @@ export function SpaceSettingsModal() {
|
||||
Discovery
|
||||
</button>
|
||||
)}
|
||||
{canManageSpace && (
|
||||
<button onClick={() => setTab('stickers')} className={tabClass('stickers')}>
|
||||
Stickers
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setTab('members')} className={tabClass('members')}>
|
||||
Members
|
||||
</button>
|
||||
@@ -326,7 +320,6 @@ export function SpaceSettingsModal() {
|
||||
<div className="flex-1 min-w-0 overflow-y-auto scrollbar-thin">
|
||||
{tab === 'overview' && <OverviewPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'discovery' && canManageSpace && <DiscoveryPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'stickers' && canManageSpace && <StickersPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
||||
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '../../../api/client';
|
||||
import { ConfirmDialog } from '../../ui/ConfirmDialog';
|
||||
import type { StickerPack, Sticker } from '@backspace/shared';
|
||||
|
||||
interface StickersPanelProps {
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
export function StickersPanel({ spaceId }: StickersPanelProps) {
|
||||
const [packs, setPacks] = useState<StickerPack[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Create pack state
|
||||
const [newPackName, setNewPackName] = useState('');
|
||||
const [newPackDesc, setNewPackDesc] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Upload sticker state
|
||||
const [uploadPackId, setUploadPackId] = useState<string | null>(null);
|
||||
const [stickerName, setStickerName] = useState('');
|
||||
const [stickerTags, setStickerTags] = useState('');
|
||||
const [stickerFile, setStickerFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Delete confirmation
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ type: 'pack' | 'sticker'; id: string; name: string } | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchPacks = async () => {
|
||||
try {
|
||||
const { packs: data } = await api.stickers.getPacks(spaceId);
|
||||
setPacks(data);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
setError('Failed to load sticker packs');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPacks();
|
||||
}, [spaceId]);
|
||||
|
||||
const handleCreatePack = async () => {
|
||||
if (!newPackName.trim()) return;
|
||||
setCreating(true);
|
||||
setError('');
|
||||
try {
|
||||
const pack = await api.stickers.createPack(spaceId, {
|
||||
name: newPackName.trim(),
|
||||
description: newPackDesc.trim() || undefined,
|
||||
});
|
||||
setPacks((prev) => [...prev, { ...pack, stickers: [] }]);
|
||||
setNewPackName('');
|
||||
setNewPackDesc('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create pack');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadSticker = async () => {
|
||||
if (!uploadPackId || !stickerFile || !stickerName.trim()) return;
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const sticker = await api.stickers.uploadSticker(
|
||||
spaceId,
|
||||
uploadPackId,
|
||||
stickerFile,
|
||||
stickerName.trim(),
|
||||
stickerTags.trim(),
|
||||
);
|
||||
setPacks((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === uploadPackId
|
||||
? { ...p, stickers: [...p.stickers, sticker] }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
setStickerName('');
|
||||
setStickerTags('');
|
||||
setStickerFile(null);
|
||||
setUploadPackId(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to upload sticker');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
setError('');
|
||||
try {
|
||||
if (deleteTarget.type === 'pack') {
|
||||
await api.stickers.deletePack(spaceId, deleteTarget.id);
|
||||
setPacks((prev) => prev.filter((p) => p.id !== deleteTarget.id));
|
||||
} else {
|
||||
await api.stickers.deleteSticker(deleteTarget.id);
|
||||
setPacks((prev) =>
|
||||
prev.map((p) => ({
|
||||
...p,
|
||||
stickers: p.stickers.filter((s) => s.id !== deleteTarget.id),
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStickerUrl = (sticker: Sticker) => {
|
||||
if (sticker.filename.startsWith('http') || sticker.filename.startsWith('/'))
|
||||
return sticker.filename;
|
||||
return `/api/uploads/${sticker.filename}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-sm text-txt-tertiary">Loading sticker packs...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
Manage sticker packs for this space. Members can use these stickers in messages.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Pack */}
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Create Sticker Pack
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.02] p-3.5 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newPackName}
|
||||
onChange={(e) => setNewPackName(e.target.value.slice(0, 32))}
|
||||
placeholder="Pack name"
|
||||
className="input-standard w-full"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newPackDesc}
|
||||
onChange={(e) => setNewPackDesc(e.target.value.slice(0, 100))}
|
||||
placeholder="Description (optional)"
|
||||
className="input-standard w-full"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreatePack}
|
||||
disabled={creating || !newPackName.trim()}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Pack'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing Packs */}
|
||||
{packs.length === 0 ? (
|
||||
<div className="text-sm text-txt-tertiary">No sticker packs yet.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{packs.map((pack) => (
|
||||
<div key={pack.id} className="rounded-lg bg-white/[0.02] p-3.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-txt-primary">{pack.name}</div>
|
||||
{pack.description && (
|
||||
<div className="text-xs text-txt-tertiary">{pack.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setUploadPackId(uploadPackId === pack.id ? null : pack.id)}
|
||||
className="px-2 py-1 text-xs text-txt-secondary hover:text-txt-primary bg-interactive-hover hover:bg-interactive-active rounded transition-colors"
|
||||
>
|
||||
{uploadPackId === pack.id ? 'Cancel' : 'Add Sticker'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget({ type: 'pack', id: pack.id, name: pack.name })}
|
||||
className="px-2 py-1 text-xs text-txt-danger hover:bg-accent-rose/20 rounded transition-colors"
|
||||
>
|
||||
Delete Pack
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload form for this pack */}
|
||||
{uploadPackId === pack.id && (
|
||||
<div className="border-t border-white/[0.06] pt-2 mt-2 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={stickerName}
|
||||
onChange={(e) => setStickerName(e.target.value.slice(0, 32))}
|
||||
placeholder="Sticker name"
|
||||
className="input-standard flex-1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={stickerTags}
|
||||
onChange={(e) => setStickerTags(e.target.value.slice(0, 100))}
|
||||
placeholder="Tags (optional)"
|
||||
className="input-standard flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/webp,image/gif"
|
||||
onChange={(e) => setStickerFile(e.target.files?.[0] ?? null)}
|
||||
className="text-sm text-txt-secondary file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-interactive-hover file:text-txt-primary hover:file:bg-interactive-active"
|
||||
/>
|
||||
<button
|
||||
onClick={handleUploadSticker}
|
||||
disabled={uploading || !stickerFile || !stickerName.trim()}
|
||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-xs font-medium rounded transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-txt-tertiary">
|
||||
PNG, WebP, or GIF. Max 512x512px, 500KB.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticker grid */}
|
||||
{pack.stickers.length > 0 && (
|
||||
<div className="grid grid-cols-5 gap-2 mt-2">
|
||||
{pack.stickers.map((sticker) => (
|
||||
<div
|
||||
key={sticker.id}
|
||||
className="relative group aspect-square rounded-lg bg-surface-base overflow-hidden"
|
||||
>
|
||||
<img
|
||||
src={getStickerUrl(sticker)}
|
||||
alt={sticker.name}
|
||||
className="w-full h-full object-contain p-1"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setDeleteTarget({ type: 'sticker', id: sticker.id, name: sticker.name })}
|
||||
className="p-1 text-white hover:text-txt-danger transition-colors"
|
||||
title="Delete sticker"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 py-0.5 text-[9px] text-white truncate opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{sticker.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pack.stickers.length === 0 && (
|
||||
<div className="text-xs text-txt-tertiary mt-1">No stickers in this pack yet.</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title={`Delete ${deleteTarget?.type === 'pack' ? 'Sticker Pack' : 'Sticker'}`}
|
||||
description={`Are you sure you want to delete "${deleteTarget?.name}"?${
|
||||
deleteTarget?.type === 'pack' ? ' All stickers in this pack will be deleted.' : ''
|
||||
} Existing messages will show "Sticker unavailable".`}
|
||||
confirmLabel={deleting ? 'Deleting...' : 'Delete'}
|
||||
onConfirm={handleDelete}
|
||||
variant="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react';
|
||||
|
||||
export function SwUpdatePrompt() {
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW();
|
||||
|
||||
if (!needRefresh) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999] glass-pill px-4 py-2.5 flex items-center gap-3 text-sm text-txt-primary shadow-lg">
|
||||
<span>A new version is available</span>
|
||||
<button
|
||||
onClick={() => updateServiceWorker(true)}
|
||||
className="px-3 py-1 rounded-md bg-accent-primary text-white text-xs font-medium hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user