refactor: remove voting system, add author colors + pin numbering
- Remove votes backend (route, db functions, server mount) - Remove votes frontend (store, socket, PinOverlay badges, FeedbackPanel UI) - Add authorColors utility (8-color palette, deterministic hash) - Add getPinNumber() to annotationStore - Fix JWT to include username/display_name - Add resolveAuthorName DB fallback for old tokens
This commit is contained in:
+1
-1
@@ -14,7 +14,7 @@ const REFBOARD_API_KEY = process.env.REFBOARD_API_KEY || '';
|
|||||||
|
|
||||||
function generateToken(user) {
|
function generateToken(user) {
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{ id: user.id, email: user.email, role: user.role },
|
{ id: user.id, email: user.email, role: user.role, username: user.username, display_name: user.display_name },
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: JWT_EXPIRES_IN }
|
{ expiresIn: JWT_EXPIRES_IN }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -527,33 +527,6 @@ function updateImageMedia(imageId, { posterAssetKey, duration, nativeWidth, nati
|
|||||||
`).run(posterAssetKey || null, duration || null, nativeWidth || null, nativeHeight || null, imageId);
|
`).run(posterAssetKey || null, duration || null, nativeWidth || null, nativeHeight || null, imageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------
|
|
||||||
// Vote helpers
|
|
||||||
// ---------------------
|
|
||||||
function getVotesByBoard(boardId) {
|
|
||||||
return db.prepare('SELECT * FROM object_votes WHERE board_id = ?').all(boardId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const _toggleVoteTx = db.transaction((boardId, objectId, userId) => {
|
|
||||||
const existing = db.prepare(
|
|
||||||
'SELECT 1 FROM object_votes WHERE board_id = ? AND object_id = ? AND user_id = ?'
|
|
||||||
).get(boardId, objectId, userId);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
db.prepare('DELETE FROM object_votes WHERE board_id = ? AND object_id = ? AND user_id = ?')
|
|
||||||
.run(boardId, objectId, userId);
|
|
||||||
return false; // vote removed
|
|
||||||
} else {
|
|
||||||
db.prepare('INSERT INTO object_votes (board_id, object_id, user_id) VALUES (?, ?, ?)')
|
|
||||||
.run(boardId, objectId, userId);
|
|
||||||
return true; // vote added
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function toggleVote(boardId, objectId, userId) {
|
|
||||||
return _toggleVoteTx(boardId, objectId, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------
|
// ---------------------
|
||||||
// Thread helpers
|
// Thread helpers
|
||||||
// ---------------------
|
// ---------------------
|
||||||
@@ -681,6 +654,4 @@ module.exports = {
|
|||||||
incrementThreadCommentCount, decrementThreadCommentCount,
|
incrementThreadCommentCount, decrementThreadCommentCount,
|
||||||
// Comments
|
// Comments
|
||||||
getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment,
|
getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment,
|
||||||
// Votes
|
|
||||||
getVotesByBoard, toggleVote,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,9 +14,19 @@ const {
|
|||||||
deleteComment,
|
deleteComment,
|
||||||
incrementThreadCommentCount,
|
incrementThreadCommentCount,
|
||||||
decrementThreadCommentCount,
|
decrementThreadCommentCount,
|
||||||
|
getUserById,
|
||||||
} = require('../db');
|
} = require('../db');
|
||||||
const { hasCollectionRole, resolveBoard } = require('./board-access');
|
const { hasCollectionRole, resolveBoard } = require('./board-access');
|
||||||
|
|
||||||
|
function resolveAuthorName(reqUser) {
|
||||||
|
if (reqUser.display_name || reqUser.username) {
|
||||||
|
return reqUser.display_name || reqUser.username;
|
||||||
|
}
|
||||||
|
// Fallback: look up from DB (old JWT tokens lack these fields)
|
||||||
|
const dbUser = getUserById(reqUser.id);
|
||||||
|
return dbUser ? (dbUser.display_name || dbUser.username || dbUser.email) : 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
@@ -86,7 +96,7 @@ router.post('/:boardId/threads', (req, res) => {
|
|||||||
id: commentId,
|
id: commentId,
|
||||||
threadId,
|
threadId,
|
||||||
userId,
|
userId,
|
||||||
authorName: req.user.display_name || req.user.username,
|
authorName: resolveAuthorName(req.user),
|
||||||
authorColor: null,
|
authorColor: null,
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
});
|
});
|
||||||
@@ -198,7 +208,7 @@ router.post('/:boardId/threads/:threadId/comments', (req, res) => {
|
|||||||
id: commentId,
|
id: commentId,
|
||||||
threadId: req.params.threadId,
|
threadId: req.params.threadId,
|
||||||
userId,
|
userId,
|
||||||
authorName: req.user.display_name || req.user.username,
|
authorName: resolveAuthorName(req.user),
|
||||||
authorColor: null,
|
authorColor: null,
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
const { Router } = require('express');
|
|
||||||
const { authMiddleware } = require('../auth');
|
|
||||||
const { getVotesByBoard, toggleVote } = require('../db');
|
|
||||||
const { resolveBoard } = require('./board-access');
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.use(authMiddleware);
|
|
||||||
|
|
||||||
// GET /api/boards/:boardId/votes — all votes for board
|
|
||||||
router.get('/:boardId/votes', (req, res) => {
|
|
||||||
try {
|
|
||||||
const result = resolveBoard(req, res, 'viewer');
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
const votes = getVotesByBoard(req.params.boardId);
|
|
||||||
return res.json({ votes });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[votes] list error:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /api/boards/:boardId/votes — toggle vote
|
|
||||||
router.post('/:boardId/votes', (req, res) => {
|
|
||||||
try {
|
|
||||||
const result = resolveBoard(req, res, 'viewer');
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
const { object_id } = req.body;
|
|
||||||
if (!object_id) {
|
|
||||||
return res.status(400).json({ error: 'object_id is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const active = toggleVote(req.params.boardId, object_id, req.user.id);
|
|
||||||
|
|
||||||
const io = req.app.get('io');
|
|
||||||
if (io) {
|
|
||||||
io.to(`board:${req.params.boardId}`).emit('vote:toggle', {
|
|
||||||
boardId: req.params.boardId,
|
|
||||||
objectId: object_id,
|
|
||||||
userId: req.user.id,
|
|
||||||
active,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({ active });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[votes] toggle error:', err);
|
|
||||||
return res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -101,7 +101,6 @@ const uploadRoutes = require('./routes/upload');
|
|||||||
const adminRoutes = require('./routes/admin');
|
const adminRoutes = require('./routes/admin');
|
||||||
const mmBridgeRoutes = require('./routes/mattermost-bridge');
|
const mmBridgeRoutes = require('./routes/mattermost-bridge');
|
||||||
const threadRoutes = require('./routes/threads');
|
const threadRoutes = require('./routes/threads');
|
||||||
const voteRoutes = require('./routes/votes');
|
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/collections', collectionRoutes);
|
app.use('/api/collections', collectionRoutes);
|
||||||
@@ -110,7 +109,6 @@ app.use('/api/upload', uploadRoutes);
|
|||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/boards', mmBridgeRoutes);
|
app.use('/api/boards', mmBridgeRoutes);
|
||||||
app.use('/api/boards', threadRoutes);
|
app.use('/api/boards', threadRoutes);
|
||||||
app.use('/api/boards', voteRoutes);
|
|
||||||
|
|
||||||
// Public shared collection route (no auth required)
|
// Public shared collection route (no auth required)
|
||||||
app.get('/api/c/:shareToken', (req, res) => {
|
app.get('/api/c/:shareToken', (req, res) => {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const PIN_COLOR_RESOLVED = 0x666666;
|
|||||||
|
|
||||||
export class PinOverlay extends Container {
|
export class PinOverlay extends Container {
|
||||||
private _pins = new Map<string, Graphics>();
|
private _pins = new Map<string, Graphics>();
|
||||||
private _voteBadges = new Map<string, Graphics>();
|
|
||||||
private _pool: Graphics[] = [];
|
private _pool: Graphics[] = [];
|
||||||
private _viewport: Viewport;
|
private _viewport: Viewport;
|
||||||
private _scene: SceneManager;
|
private _scene: SceneManager;
|
||||||
@@ -41,11 +40,9 @@ export class PinOverlay extends Container {
|
|||||||
refresh(showResolved = false) {
|
refresh(showResolved = false) {
|
||||||
const scale = 1 / this._viewport.scale.x;
|
const scale = 1 / this._viewport.scale.x;
|
||||||
|
|
||||||
// Return current pins and badges to pool
|
// Return current pins to pool
|
||||||
for (const gfx of this._pins.values()) this._release(gfx);
|
for (const gfx of this._pins.values()) this._release(gfx);
|
||||||
for (const gfx of this._voteBadges.values()) this._release(gfx);
|
|
||||||
this._pins.clear();
|
this._pins.clear();
|
||||||
this._voteBadges.clear();
|
|
||||||
|
|
||||||
// ── Thread pins ──
|
// ── Thread pins ──
|
||||||
for (const thread of this._store.threads.values()) {
|
for (const thread of this._store.threads.values()) {
|
||||||
@@ -89,35 +86,6 @@ export class PinOverlay extends Container {
|
|||||||
if (!gfx.parent) this.addChild(gfx);
|
if (!gfx.parent) this.addChild(gfx);
|
||||||
this._pins.set(thread.id, gfx);
|
this._pins.set(thread.id, gfx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vote badges ──
|
|
||||||
for (const [objectId, voters] of this._store.votes) {
|
|
||||||
if (voters.size === 0) continue;
|
|
||||||
const item = this._scene.items.get(objectId);
|
|
||||||
if (!item || !item.displayObject) continue;
|
|
||||||
|
|
||||||
const bounds = item.displayObject.getBounds();
|
|
||||||
const wx = bounds.x + bounds.width;
|
|
||||||
const wy = bounds.y + bounds.height;
|
|
||||||
|
|
||||||
const gfx = this._acquire();
|
|
||||||
const pw = 24 * scale;
|
|
||||||
const ph = 16 * scale;
|
|
||||||
const pr = 4 * scale;
|
|
||||||
gfx.roundRect(-pw / 2, -ph / 2, pw, ph, pr);
|
|
||||||
gfx.fill({ color: 0x2a3a50, alpha: 0.9 });
|
|
||||||
gfx.position.set(wx - 16 * scale, wy - 4 * scale);
|
|
||||||
|
|
||||||
const label = new Text({
|
|
||||||
text: `${voters.size}`,
|
|
||||||
style: new TextStyle({ fontSize: 9 * scale, fill: '#4a9eff', fontWeight: 'bold' }),
|
|
||||||
});
|
|
||||||
label.anchor.set(0.5);
|
|
||||||
gfx.addChild(label);
|
|
||||||
|
|
||||||
if (!gfx.parent) this.addChild(gfx);
|
|
||||||
this._voteBadges.set(objectId, gfx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getThreadIdAtPoint(worldX: number, worldY: number): string | null {
|
getThreadIdAtPoint(worldX: number, worldY: number): string | null {
|
||||||
@@ -134,10 +102,8 @@ export class PinOverlay extends Container {
|
|||||||
|
|
||||||
override destroy(options?: any) {
|
override destroy(options?: any) {
|
||||||
for (const gfx of this._pins.values()) gfx.destroy();
|
for (const gfx of this._pins.values()) gfx.destroy();
|
||||||
for (const gfx of this._voteBadges.values()) gfx.destroy();
|
|
||||||
for (const gfx of this._pool) gfx.destroy();
|
for (const gfx of this._pool) gfx.destroy();
|
||||||
this._pins.clear();
|
this._pins.clear();
|
||||||
this._voteBadges.clear();
|
|
||||||
this._pool = [];
|
this._pool = [];
|
||||||
super.destroy(options);
|
super.destroy(options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,16 +101,6 @@ export default function FeedbackPanel({
|
|||||||
} catch {}
|
} catch {}
|
||||||
}, [boardId, token, apiFetch]);
|
}, [boardId, token, apiFetch]);
|
||||||
|
|
||||||
const toggleVote = useCallback(async (objectId: string) => {
|
|
||||||
try {
|
|
||||||
await apiFetch(`/api/boards/${boardId}/votes`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
||||||
body: JSON.stringify({ object_id: objectId }),
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
}, [boardId, token, apiFetch]);
|
|
||||||
|
|
||||||
const createThread = useCallback(async () => {
|
const createThread = useCallback(async () => {
|
||||||
if (!newCommentText.trim() || !selectedObjectId) return;
|
if (!newCommentText.trim() || !selectedObjectId) return;
|
||||||
try {
|
try {
|
||||||
@@ -166,15 +156,6 @@ export default function FeedbackPanel({
|
|||||||
<span style={{ color: '#aaa', fontSize: '12px', flex: 1 }}>
|
<span style={{ color: '#aaa', fontSize: '12px', flex: 1 }}>
|
||||||
{expandedThread.status === 'resolved' ? 'Resolved' : 'Open'}
|
{expandedThread.status === 'resolved' ? 'Resolved' : 'Open'}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => toggleVote(expandedThread.object_id)} style={{
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
color: annotationStore.hasVoted(expandedThread.object_id, userId) ? '#4a9eff' : '#444',
|
|
||||||
fontSize: '11px',
|
|
||||||
}}>
|
|
||||||
{annotationStore.getVoteCount(expandedThread.object_id) > 0
|
|
||||||
? `+${annotationStore.getVoteCount(expandedThread.object_id)}`
|
|
||||||
: '+'}
|
|
||||||
</button>
|
|
||||||
{onJumpToObject && (
|
{onJumpToObject && (
|
||||||
<button onClick={() => onJumpToObject(expandedThread.object_id)} style={{
|
<button onClick={() => onJumpToObject(expandedThread.object_id)} style={{
|
||||||
background: 'none', border: 'none', color: '#4a9eff', cursor: 'pointer', fontSize: '10px',
|
background: 'none', border: 'none', color: '#4a9eff', cursor: 'pointer', fontSize: '10px',
|
||||||
@@ -336,15 +317,6 @@ export default function FeedbackPanel({
|
|||||||
{obj?.name || obj?.type || t.object_id.slice(0, 8)}
|
{obj?.name || obj?.type || t.object_id.slice(0, 8)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: '#555', fontSize: '10px' }}>{t.comment_count}</span>
|
<span style={{ color: '#555', fontSize: '10px' }}>{t.comment_count}</span>
|
||||||
<button onClick={(e) => { e.stopPropagation(); toggleVote(t.object_id); }} style={{
|
|
||||||
background: 'none', border: 'none', cursor: 'pointer', padding: '0 2px',
|
|
||||||
color: annotationStore.hasVoted(t.object_id, userId) ? '#4a9eff' : '#444',
|
|
||||||
fontSize: '11px', flexShrink: 0,
|
|
||||||
}}>
|
|
||||||
{annotationStore.getVoteCount(t.object_id) > 0
|
|
||||||
? `+${annotationStore.getVoteCount(t.object_id)}`
|
|
||||||
: '+'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{firstComment && (
|
{firstComment && (
|
||||||
<div style={{ color: '#777', fontSize: '12px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<div style={{ color: '#777', fontSize: '12px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
|||||||
@@ -304,8 +304,8 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Annotations: load threads + votes, wire socket events ──
|
// ── Annotations: load threads, wire socket events ──
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('refboard_token');
|
||||||
if (token) {
|
if (token) {
|
||||||
fetch(`/api/boards/${resolvedBoardId}/threads`, {
|
fetch(`/api/boards/${resolvedBoardId}/threads`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
@@ -315,15 +315,6 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
if (data.threads) annotationStoreRef.current?.loadThreads(data.threads);
|
if (data.threads) annotationStoreRef.current?.loadThreads(data.threads);
|
||||||
})
|
})
|
||||||
.catch((err) => console.error('[annotations] load threads error:', err));
|
.catch((err) => console.error('[annotations] load threads error:', err));
|
||||||
|
|
||||||
fetch(`/api/boards/${resolvedBoardId}/votes`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.votes) annotationStoreRef.current?.loadVotes(data.votes);
|
|
||||||
})
|
|
||||||
.catch((err) => console.error('[annotations] load votes error:', err));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('thread:add', (data: any) => {
|
socket.on('thread:add', (data: any) => {
|
||||||
@@ -344,10 +335,6 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
|
|||||||
socket.on('comment:delete', (data: any) => {
|
socket.on('comment:delete', (data: any) => {
|
||||||
annotationStoreRef.current?.onCommentDelete(data.threadId, data.commentId);
|
annotationStoreRef.current?.onCommentDelete(data.threadId, data.commentId);
|
||||||
});
|
});
|
||||||
socket.on('vote:toggle', (data: any) => {
|
|
||||||
annotationStoreRef.current?.onVoteToggle(data.objectId, data.userId, data.active);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Pin overlay (hidden by default, shown in Review Mode) ──
|
// ── Pin overlay (hidden by default, shown in Review Mode) ──
|
||||||
const pinOverlay = new PinOverlay(viewport, scene, annotationStoreRef.current!);
|
const pinOverlay = new PinOverlay(viewport, scene, annotationStoreRef.current!);
|
||||||
pinOverlay.visible = false;
|
pinOverlay.visible = false;
|
||||||
|
|||||||
@@ -675,7 +675,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
selectedObjectId={selectedLayerIds.length === 1 ? selectedLayerIds[0] : null}
|
selectedObjectId={selectedLayerIds.length === 1 ? selectedLayerIds[0] : null}
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
boardId={resolvedBoardId}
|
boardId={resolvedBoardId}
|
||||||
token={localStorage.getItem('token') || ''}
|
token={localStorage.getItem('refboard_token') || ''}
|
||||||
canvasObjects={canvasObjectMap}
|
canvasObjects={canvasObjectMap}
|
||||||
onError={(msg) => showToast(msg)}
|
onError={(msg) => showToast(msg)}
|
||||||
onJumpToObject={(objectId) => {
|
onJumpToObject={(objectId) => {
|
||||||
|
|||||||
@@ -28,18 +28,10 @@ export interface Comment {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Vote {
|
|
||||||
board_id: string;
|
|
||||||
object_id: string;
|
|
||||||
user_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Listener = () => void;
|
type Listener = () => void;
|
||||||
|
|
||||||
export class AnnotationStore {
|
export class AnnotationStore {
|
||||||
threads = new Map<string, Thread>();
|
threads = new Map<string, Thread>();
|
||||||
/** objectId → Set<userId> */
|
|
||||||
votes = new Map<string, Set<string>>();
|
|
||||||
|
|
||||||
private _listeners = new Set<Listener>();
|
private _listeners = new Set<Listener>();
|
||||||
private _version = 0;
|
private _version = 0;
|
||||||
@@ -67,18 +59,8 @@ export class AnnotationStore {
|
|||||||
this._notify();
|
this._notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
loadVotes(votes: Vote[]) {
|
|
||||||
this.votes.clear();
|
|
||||||
for (const v of votes) {
|
|
||||||
if (!this.votes.has(v.object_id)) this.votes.set(v.object_id, new Set());
|
|
||||||
this.votes.get(v.object_id)!.add(v.user_id);
|
|
||||||
}
|
|
||||||
this._notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.threads.clear();
|
this.threads.clear();
|
||||||
this.votes.clear();
|
|
||||||
this._notify();
|
this._notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,16 +115,16 @@ export class AnnotationStore {
|
|||||||
this._notify();
|
this._notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
onVoteToggle(objectId: string, userId: string, active: boolean) {
|
|
||||||
if (!this.votes.has(objectId)) this.votes.set(objectId, new Set());
|
|
||||||
const set = this.votes.get(objectId)!;
|
|
||||||
if (active) set.add(userId); else set.delete(userId);
|
|
||||||
if (set.size === 0) this.votes.delete(objectId);
|
|
||||||
this._notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Convenience getters ──
|
// ── Convenience getters ──
|
||||||
|
|
||||||
|
/** Get 1-based sequential pin number for a thread on this board */
|
||||||
|
getPinNumber(threadId: string): number {
|
||||||
|
const sorted = Array.from(this.threads.values())
|
||||||
|
.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
||||||
|
const idx = sorted.findIndex((t) => t.id === threadId);
|
||||||
|
return idx + 1;
|
||||||
|
}
|
||||||
|
|
||||||
getThreadsForObject(objectId: string): Thread[] {
|
getThreadsForObject(objectId: string): Thread[] {
|
||||||
const result: Thread[] = [];
|
const result: Thread[] = [];
|
||||||
for (const t of this.threads.values()) {
|
for (const t of this.threads.values()) {
|
||||||
@@ -151,11 +133,4 @@ export class AnnotationStore {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
getVoteCount(objectId: string): number {
|
|
||||||
return this.votes.get(objectId)?.size ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasVoted(objectId: string, userId: string): boolean {
|
|
||||||
return this.votes.get(objectId)?.has(userId) ?? false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const AUTHOR_COLORS = [
|
||||||
|
'#4a9eff', // blue
|
||||||
|
'#f97316', // orange
|
||||||
|
'#22c55e', // green
|
||||||
|
'#a855f7', // purple
|
||||||
|
'#ef4444', // red
|
||||||
|
'#06b6d4', // cyan
|
||||||
|
'#eab308', // yellow
|
||||||
|
'#ec4899', // pink
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUTHOR_COLORS_HEX = [
|
||||||
|
0x4a9eff, 0xf97316, 0x22c55e, 0xa855f7,
|
||||||
|
0xef4444, 0x06b6d4, 0xeab308, 0xec4899,
|
||||||
|
];
|
||||||
|
|
||||||
|
function hashCode(str: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthorColor(userId: string): string {
|
||||||
|
return AUTHOR_COLORS[hashCode(userId) % AUTHOR_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthorColorHex(userId: string): number {
|
||||||
|
return AUTHOR_COLORS_HEX[hashCode(userId) % AUTHOR_COLORS_HEX.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthorInitial(name: string): string {
|
||||||
|
return (name || '?').charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user