mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-07-29 05:53:50 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -8,7 +8,7 @@ import { Bookmarks } from '@/pages/Bookmarks'
|
||||
import { Tasks } from '@/pages/Tasks'
|
||||
import { Files } from '@/pages/Files'
|
||||
import { Notes } from '@/pages/Notes'
|
||||
import { AIChat } from '@/pages/AIChat'
|
||||
import Chat from '@/pages/Chat'
|
||||
import { Settings } from '@/pages/Settings'
|
||||
import { Login } from '@/pages/Login'
|
||||
import { Youtube } from '@/pages/Youtube'
|
||||
@@ -28,6 +28,7 @@ import { AuthProvider, useAuth } from '@/lib/auth'
|
||||
import { Search } from '@/pages/Search'
|
||||
import { Analytics } from '@/pages/Analytics'
|
||||
import { Messages } from '@/pages/Messages'
|
||||
import { ShareTarget } from '@/pages/ShareTarget'
|
||||
import BrowserExtensionSettings from '@/pages/BrowserExtensionSettings'
|
||||
import { initializeDemoMode, clearDemoMode, isEnvDemoMode } from '@/lib/demo-mode'
|
||||
import { onMount, createEffect } from 'solid-js'
|
||||
@@ -134,6 +135,7 @@ function App() {
|
||||
<Route path="/" component={RootRoute} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/auth/callback" component={AuthCallback} />
|
||||
<Route path="/share-target" component={ShareTarget} />
|
||||
<Route path="/app" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="Dashboard">
|
||||
@@ -207,7 +209,7 @@ function App() {
|
||||
<Route path="/app/chat" component={() => (
|
||||
<ProtectedRoute>
|
||||
<Layout title="AI Chat" fullBleed>
|
||||
<AIChat />
|
||||
<Chat />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
)} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Sidebar } from './Sidebar'
|
||||
import { Header } from './Header'
|
||||
import { AIChatPanel } from './AIChatPanel'
|
||||
import { IconBrain } from '@tabler/icons-solidjs'
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode'
|
||||
|
||||
export interface LayoutProps {
|
||||
children: any
|
||||
@@ -27,8 +28,11 @@ export function Layout(props: LayoutProps) {
|
||||
// Initialize dark mode from localStorage or system preference
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) {
|
||||
|
||||
if (isEnvDemoMode()) {
|
||||
localStorage.setItem('theme', 'dark')
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark')
|
||||
} else if (savedTheme === 'dark' || (!savedTheme && systemPrefersDark)) {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark')
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-kb-theme')
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
@import "highlight.js/styles/github-dark.css";
|
||||
|
||||
.note-renderer {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.note-renderer .hljs {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.note-renderer pre code {
|
||||
font-family: "JetBrains Mono", "Fira Code", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import { For, Show, createMemo, createSignal, type JSX } from 'solid-js';
|
||||
|
||||
import './NoteContentRenderer.css';
|
||||
|
||||
type NoteKind = 'markdown' | 'plain' | 'html';
|
||||
|
||||
type NoteBlock =
|
||||
| { type: 'heading'; level: 1 | 2 | 3 | 4; text: string }
|
||||
| { type: 'paragraph'; text: string }
|
||||
| { type: 'bullet-list'; items: string[] }
|
||||
| { type: 'ordered-list'; items: string[] }
|
||||
| { type: 'task-list'; items: Array<{ text: string; checked: boolean; taskIndex: number }> }
|
||||
| { type: 'quote'; text: string }
|
||||
| { type: 'code'; code: string; language?: string };
|
||||
|
||||
interface NoteContentRendererProps {
|
||||
content: string;
|
||||
kind: NoteKind;
|
||||
preview?: boolean;
|
||||
maxBlocks?: number;
|
||||
onToggleTask?: (taskIndex: number, nextChecked: boolean) => void;
|
||||
}
|
||||
|
||||
type SupportedCodeLanguage =
|
||||
| 'auto'
|
||||
| 'bash'
|
||||
| 'go'
|
||||
| 'html'
|
||||
| 'javascript'
|
||||
| 'json'
|
||||
| 'markdown'
|
||||
| 'plaintext'
|
||||
| 'typescript'
|
||||
| 'xml';
|
||||
|
||||
const CODE_LANGUAGE_OPTIONS: Array<{ value: SupportedCodeLanguage; label: string }> = [
|
||||
{ value: 'auto', label: 'Auto detect' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'bash', label: 'Bash / Shell' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
{ value: 'plaintext', label: 'Plain text' },
|
||||
];
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, SupportedCodeLanguage> = {
|
||||
auto: 'auto',
|
||||
bash: 'bash',
|
||||
console: 'bash',
|
||||
go: 'go',
|
||||
golang: 'go',
|
||||
html: 'html',
|
||||
javascript: 'javascript',
|
||||
js: 'javascript',
|
||||
json: 'json',
|
||||
markdown: 'markdown',
|
||||
md: 'markdown',
|
||||
plaintext: 'plaintext',
|
||||
shell: 'bash',
|
||||
sh: 'bash',
|
||||
text: 'plaintext',
|
||||
ts: 'typescript',
|
||||
typescript: 'typescript',
|
||||
xml: 'xml',
|
||||
};
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
const normalizeCodeLanguage = (value?: string): SupportedCodeLanguage => {
|
||||
if (!value) return 'auto';
|
||||
return LANGUAGE_ALIASES[value.trim().toLowerCase()] || 'auto';
|
||||
};
|
||||
|
||||
const inferCodeLanguage = (code: string, explicitLanguage?: string): SupportedCodeLanguage => {
|
||||
const normalizedExplicit = normalizeCodeLanguage(explicitLanguage);
|
||||
if (normalizedExplicit !== 'auto') {
|
||||
return normalizedExplicit;
|
||||
}
|
||||
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) {
|
||||
return 'plaintext';
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return 'json';
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
|
||||
if (/^\s*</.test(trimmed) && /<\/?[a-z][\s\S]*>/i.test(trimmed)) {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
if (/\bfunc\s+\w+\s*\(|\bfmt\.\w+\(/.test(trimmed)) {
|
||||
return 'go';
|
||||
}
|
||||
|
||||
if (/^\s*[$#]/m.test(trimmed) || /\b(curl|npm|pnpm|yarn|git)\b/.test(trimmed)) {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
if (/\b(interface|type|readonly|as const)\b|:\s*[A-Z][A-Za-z0-9_<>,[\]?| ]*/.test(trimmed)) {
|
||||
return 'typescript';
|
||||
}
|
||||
|
||||
if (/\b(const|let|var|function|=>|import|export)\b/.test(trimmed)) {
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
if (/^#{1,6}\s/m.test(trimmed) || /\[[^\]]+\]\(([^)]+)\)/.test(trimmed)) {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
const autoDetected = hljs.highlightAuto(trimmed, ['json', 'typescript', 'javascript', 'go', 'bash', 'xml']).language;
|
||||
return normalizeCodeLanguage(autoDetected || 'plaintext');
|
||||
};
|
||||
|
||||
const renderInlineText = (text: string) => {
|
||||
const normalized = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1 ($2)');
|
||||
const nodes: Array<string | JSX.Element> = [];
|
||||
const pattern = /(\[[^\]]+\]\(([^)]+)\)|`([^`]+)`|\*\*([^*]+)\*\*|\*([^*]+)\*|(https?:\/\/[^\s]+))/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(normalized)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
nodes.push(normalized.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
const token = match[0];
|
||||
const markdownLink = match[1];
|
||||
const linkHref = match[2];
|
||||
const inlineCode = match[3];
|
||||
const strongText = match[4];
|
||||
const emphasisText = match[5];
|
||||
const directUrl = match[6];
|
||||
|
||||
if (markdownLink && linkHref) {
|
||||
const linkMatch = markdownLink.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
|
||||
const label = linkMatch?.[1] || markdownLink;
|
||||
const href = linkMatch?.[2] || linkHref;
|
||||
nodes.push(
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
} else if (inlineCode) {
|
||||
nodes.push(
|
||||
<code class="rounded-md border border-border/60 bg-muted/80 px-1.5 py-0.5 font-mono text-[0.92em] text-primary">
|
||||
{inlineCode}
|
||||
</code>
|
||||
);
|
||||
} else if (strongText) {
|
||||
nodes.push(<strong class="font-semibold text-foreground">{strongText}</strong>);
|
||||
} else if (emphasisText) {
|
||||
nodes.push(<em class="italic">{emphasisText}</em>);
|
||||
} else {
|
||||
nodes.push(
|
||||
<a href={directUrl || token} target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
|
||||
{directUrl || token}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + token.length;
|
||||
}
|
||||
|
||||
if (lastIndex < normalized.length) {
|
||||
nodes.push(normalized.slice(lastIndex));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
const parseTextBlocks = (content: string, kind: Exclude<NoteKind, 'html'>): NoteBlock[] => {
|
||||
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
||||
const blocks: NoteBlock[] = [];
|
||||
let index = 0;
|
||||
let taskIndex = 0;
|
||||
|
||||
const isMarkdown = kind === 'markdown';
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const codeFenceMatch = isMarkdown ? trimmed.match(/^```([\w-]+)?\s*$/) : null;
|
||||
if (codeFenceMatch) {
|
||||
const codeLines: string[] = [];
|
||||
const language = codeFenceMatch[1];
|
||||
index += 1;
|
||||
while (index < lines.length && !lines[index].trim().match(/^```$/)) {
|
||||
codeLines.push(lines[index]);
|
||||
index += 1;
|
||||
}
|
||||
if (index < lines.length) {
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: 'code', code: codeLines.join('\n'), language });
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = isMarkdown ? line.match(/^(#{1,4})\s+(.*)$/) : null;
|
||||
if (headingMatch) {
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
level: headingMatch[1].length as 1 | 2 | 3 | 4,
|
||||
text: headingMatch[2].trim(),
|
||||
});
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^- \[( |x|X)\]\s+/.test(trimmed)) {
|
||||
const items: Array<{ text: string; checked: boolean; taskIndex: number }> = [];
|
||||
while (index < lines.length) {
|
||||
const taskMatch = lines[index].trim().match(/^- \[( |x|X)\]\s+(.*)$/);
|
||||
if (!taskMatch) break;
|
||||
items.push({
|
||||
text: taskMatch[2],
|
||||
checked: taskMatch[1].toLowerCase() === 'x',
|
||||
taskIndex,
|
||||
});
|
||||
taskIndex += 1;
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: 'task-list', items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^- /.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (index < lines.length) {
|
||||
const listMatch = lines[index].trim().match(/^- (.*)$/);
|
||||
if (!listMatch || /^- \[( |x|X)\]\s+/.test(lines[index].trim())) break;
|
||||
items.push(listMatch[1]);
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: 'bullet-list', items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (index < lines.length) {
|
||||
const listMatch = lines[index].trim().match(/^\d+\.\s+(.*)$/);
|
||||
if (!listMatch) break;
|
||||
items.push(listMatch[1]);
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: 'ordered-list', items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s+/.test(trimmed)) {
|
||||
const quoteLines: string[] = [];
|
||||
while (index < lines.length) {
|
||||
const quoteMatch = lines[index].trim().match(/^>\s+(.*)$/);
|
||||
if (!quoteMatch) break;
|
||||
quoteLines.push(quoteMatch[1]);
|
||||
index += 1;
|
||||
}
|
||||
blocks.push({ type: 'quote', text: quoteLines.join(' ') });
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraphLines: string[] = [];
|
||||
while (index < lines.length) {
|
||||
const paragraphLine = lines[index];
|
||||
const paragraphTrimmed = paragraphLine.trim();
|
||||
if (!paragraphTrimmed) break;
|
||||
if (isMarkdown && /^(#{1,4})\s+/.test(paragraphLine)) break;
|
||||
if (isMarkdown && /^```/.test(paragraphTrimmed)) break;
|
||||
if (/^- \[( |x|X)\]\s+/.test(paragraphTrimmed)) break;
|
||||
if (/^- /.test(paragraphTrimmed)) break;
|
||||
if (/^\d+\.\s+/.test(paragraphTrimmed)) break;
|
||||
if (/^>\s+/.test(paragraphTrimmed)) break;
|
||||
paragraphLines.push(paragraphTrimmed);
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (paragraphLines.length > 0) {
|
||||
blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') });
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const CodeBlock = (props: {
|
||||
code: string;
|
||||
initialLanguage?: string;
|
||||
preview?: boolean;
|
||||
}) => {
|
||||
const autoDetectedLanguage = createMemo(() => inferCodeLanguage(props.code, props.initialLanguage));
|
||||
const [selectedLanguage, setSelectedLanguage] = createSignal<SupportedCodeLanguage>(
|
||||
normalizeCodeLanguage(props.initialLanguage)
|
||||
);
|
||||
|
||||
const effectiveLanguage = createMemo<SupportedCodeLanguage>(() => {
|
||||
const selected = selectedLanguage();
|
||||
if (selected === 'auto') {
|
||||
return autoDetectedLanguage();
|
||||
}
|
||||
return selected;
|
||||
});
|
||||
|
||||
const highlightedHtml = createMemo(() => {
|
||||
const activeLanguage = effectiveLanguage();
|
||||
if (activeLanguage === 'plaintext') {
|
||||
return escapeHtml(props.code);
|
||||
}
|
||||
|
||||
try {
|
||||
const languageForHighlight = activeLanguage === 'html' ? 'xml' : activeLanguage;
|
||||
return hljs.highlight(props.code, { language: languageForHighlight }).value;
|
||||
} catch {
|
||||
return escapeHtml(props.code);
|
||||
}
|
||||
});
|
||||
|
||||
const displayLanguageLabel = createMemo(() => {
|
||||
const activeLanguage = effectiveLanguage();
|
||||
const match = CODE_LANGUAGE_OPTIONS.find((option) => option.value === activeLanguage);
|
||||
return match?.label || activeLanguage;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl border border-border/70 bg-[#0f1724] shadow-[0_20px_40px_-28px_rgba(15,23,36,0.9)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 border-b border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div class="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-slate-300">
|
||||
<span class="rounded-full border border-white/12 bg-white/8 px-2.5 py-1">{displayLanguageLabel()}</span>
|
||||
<Show when={selectedLanguage() === 'auto'}>
|
||||
<span class="text-[11px] tracking-[0.14em] text-slate-500">Auto</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!props.preview}>
|
||||
<select
|
||||
value={selectedLanguage()}
|
||||
class="rounded-lg border border-white/12 bg-slate-950/70 px-3 py-1.5 text-xs text-slate-100 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => setSelectedLanguage(event.currentTarget.value as SupportedCodeLanguage)}
|
||||
>
|
||||
<For each={CODE_LANGUAGE_OPTIONS}>
|
||||
{(option) => <option value={option.value}>{option.label}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</Show>
|
||||
</div>
|
||||
<pre class={`overflow-x-auto px-4 py-4 text-sm leading-6 text-slate-100 ${props.preview ? 'max-h-72' : ''}`}>
|
||||
<code class={`hljs language-${effectiveLanguage()}`} innerHTML={highlightedHtml()} />
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoteContentRenderer = (props: NoteContentRendererProps) => {
|
||||
const blocks = createMemo(() => {
|
||||
if (props.kind === 'html') {
|
||||
return [] as NoteBlock[];
|
||||
}
|
||||
return parseTextBlocks(props.content, props.kind);
|
||||
});
|
||||
|
||||
const visibleBlocks = createMemo(() => {
|
||||
if (!props.preview) {
|
||||
return blocks();
|
||||
}
|
||||
return blocks().slice(0, props.maxBlocks ?? 4);
|
||||
});
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.kind !== 'html'}
|
||||
fallback={
|
||||
<div
|
||||
class="note-renderer prose prose-invert max-w-none text-sm leading-6 text-foreground"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
innerHTML={props.content}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class={`note-renderer space-y-4 ${props.preview ? 'overflow-hidden' : ''}`}>
|
||||
<For each={visibleBlocks()}>
|
||||
{(block) => (
|
||||
<Show
|
||||
when={block.type === 'heading'}
|
||||
fallback={
|
||||
<Show
|
||||
when={block.type === 'paragraph'}
|
||||
fallback={
|
||||
<Show
|
||||
when={block.type === 'bullet-list'}
|
||||
fallback={
|
||||
<Show
|
||||
when={block.type === 'ordered-list'}
|
||||
fallback={
|
||||
<Show
|
||||
when={block.type === 'task-list'}
|
||||
fallback={
|
||||
<Show
|
||||
when={block.type === 'quote'}
|
||||
fallback={
|
||||
<CodeBlock
|
||||
code={(block as Extract<NoteBlock, { type: 'code' }>).code}
|
||||
initialLanguage={(block as Extract<NoteBlock, { type: 'code' }>).language}
|
||||
preview={props.preview}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<blockquote class="rounded-r-xl border-l-4 border-primary/60 bg-muted/40 px-4 py-3 text-sm italic text-muted-foreground">
|
||||
{renderInlineText((block as Extract<NoteBlock, { type: 'quote' }>).text)}
|
||||
</blockquote>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="space-y-2" onClick={(event) => event.stopPropagation()}>
|
||||
<For each={(block as Extract<NoteBlock, { type: 'task-list' }>).items}>
|
||||
{(item) => (
|
||||
<label class="flex items-start gap-3 rounded-xl border border-border/60 bg-muted/30 px-3 py-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
class="mt-0.5 h-4 w-4 cursor-pointer accent-[hsl(var(--primary))]"
|
||||
disabled={!props.onToggleTask}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={(event) => props.onToggleTask?.(item.taskIndex, event.currentTarget.checked)}
|
||||
/>
|
||||
<span class={`text-sm leading-6 ${item.checked ? 'text-muted-foreground line-through' : 'text-foreground'}`}>
|
||||
{renderInlineText(item.text)}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<ol class="list-decimal space-y-2 pl-5 text-sm leading-6 text-foreground">
|
||||
<For each={(block as Extract<NoteBlock, { type: 'ordered-list' }>).items}>
|
||||
{(item) => <li>{renderInlineText(item)}</li>}
|
||||
</For>
|
||||
</ol>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<ul class="list-disc space-y-2 pl-5 text-sm leading-6 text-foreground">
|
||||
<For each={(block as Extract<NoteBlock, { type: 'bullet-list' }>).items}>
|
||||
{(item) => <li>{renderInlineText(item)}</li>}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<p class="text-sm leading-7 text-muted-foreground">{renderInlineText((block as Extract<NoteBlock, { type: 'paragraph' }>).text)}</p>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(() => {
|
||||
const heading = block as Extract<NoteBlock, { type: 'heading' }>;
|
||||
const className = {
|
||||
1: 'text-xl font-semibold tracking-tight text-foreground',
|
||||
2: 'text-lg font-semibold tracking-tight text-foreground',
|
||||
3: 'text-base font-semibold text-foreground',
|
||||
4: 'text-sm font-semibold uppercase tracking-[0.12em] text-muted-foreground',
|
||||
}[heading.level];
|
||||
|
||||
return <div class={className}>{renderInlineText(heading.text)}</div>;
|
||||
})()}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -39,6 +39,14 @@ interface GitHubActivityProps {
|
||||
customEvents?: ActivityEvent[];
|
||||
hideHeader?: boolean;
|
||||
fullWidth?: boolean;
|
||||
externalData?: {
|
||||
contributions: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
level: number;
|
||||
}>;
|
||||
total_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
@@ -141,11 +149,54 @@ export const GitHubActivity = (props: GitHubActivityProps) => {
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
if (isDemoMode()) {
|
||||
// Listen for external GitHub activity data
|
||||
const handleGitHubActivityData = (event: CustomEvent) => {
|
||||
const data = event.detail as {
|
||||
contributions: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
level: number;
|
||||
}>;
|
||||
total_count: number;
|
||||
};
|
||||
|
||||
if (data.contributions && data.contributions.length > 0) {
|
||||
const activityData = data.contributions.map(c => ({
|
||||
date: c.date,
|
||||
count: c.count,
|
||||
level: c.level
|
||||
}));
|
||||
setActivities(activityData);
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalContributions: data.total_count
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('githubActivityData', handleGitHubActivityData as EventListener);
|
||||
|
||||
if (props.externalData) {
|
||||
// Use provided external data
|
||||
const activityData = props.externalData.contributions.map(c => ({
|
||||
date: c.date,
|
||||
count: c.count,
|
||||
level: c.level
|
||||
}));
|
||||
setActivities(activityData);
|
||||
setStats(prev => ({
|
||||
...prev,
|
||||
totalContributions: props.externalData!.total_count
|
||||
}));
|
||||
} else if (isDemoMode()) {
|
||||
setDemoData();
|
||||
} else {
|
||||
setEmptyData();
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('githubActivityData', handleGitHubActivityData as EventListener);
|
||||
};
|
||||
});
|
||||
|
||||
const getMonthLabels = () => {
|
||||
|
||||
@@ -25,26 +25,39 @@ const ToastItem = (props: ToastProps) => {
|
||||
const getIcon = () => {
|
||||
switch (props.toast.type) {
|
||||
case 'success':
|
||||
return <IconCheck class="h-5 w-5 text-green-500" />;
|
||||
return <IconCheck class="h-4 w-4 text-emerald-500" />;
|
||||
case 'error':
|
||||
return <IconAlertTriangle class="h-5 w-5 text-destructive" />;
|
||||
return <IconAlertTriangle class="h-4 w-4 text-destructive" />;
|
||||
case 'warning':
|
||||
return <IconAlertTriangle class="h-5 w-5 text-warning" />;
|
||||
return <IconAlertTriangle class="h-4 w-4 text-amber-500" />;
|
||||
case 'info':
|
||||
return <IconInfoCircle class="h-5 w-5 text-primary" />;
|
||||
return <IconInfoCircle class="h-4 w-4 text-primary" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
const getToneClasses = () => {
|
||||
switch (props.toast.type) {
|
||||
case 'success':
|
||||
return 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800';
|
||||
return 'border-emerald-500/20 bg-emerald-500/8';
|
||||
case 'error':
|
||||
return 'bg-destructive/10 border-destructive/20';
|
||||
return 'border-destructive/25 bg-destructive/8';
|
||||
case 'warning':
|
||||
return 'bg-warning/10 border-warning/20';
|
||||
return 'border-amber-500/25 bg-amber-500/8';
|
||||
case 'info':
|
||||
return 'bg-primary/10 border-primary/20';
|
||||
return 'border-primary/25 bg-primary/8';
|
||||
}
|
||||
};
|
||||
|
||||
const getIconSurface = () => {
|
||||
switch (props.toast.type) {
|
||||
case 'success':
|
||||
return 'bg-emerald-500/12 ring-1 ring-emerald-500/15';
|
||||
case 'error':
|
||||
return 'bg-destructive/12 ring-1 ring-destructive/15';
|
||||
case 'warning':
|
||||
return 'bg-amber-500/12 ring-1 ring-amber-500/15';
|
||||
case 'info':
|
||||
return 'bg-primary/12 ring-1 ring-primary/15';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,34 +70,34 @@ const ToastItem = (props: ToastProps) => {
|
||||
return (
|
||||
<div
|
||||
class={`transform transition-all duration-300 ease-in-out ${
|
||||
isVisible() ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
|
||||
isVisible() ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-2 scale-95 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
class={`max-w-sm w-full ${getBackgroundColor()} border rounded-lg shadow-lg p-4 mb-4`}
|
||||
class={`max-w-sm w-full rounded-2xl border px-4 py-3 shadow-2xl shadow-black/10 backdrop-blur-xl ${getToneClasses()}`}
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${getIconSurface()}`}>
|
||||
{getIcon()}
|
||||
</div>
|
||||
<div class="ml-3 w-0 flex-1">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-semibold text-foreground">
|
||||
{props.toast.title}
|
||||
</p>
|
||||
{props.toast.message && (
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
<p class="mt-1 text-sm leading-5 text-muted-foreground">
|
||||
{props.toast.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0 flex">
|
||||
<div class="flex shrink-0">
|
||||
<button
|
||||
class="inline-flex text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary rounded"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<IconX class="h-5 w-5" />
|
||||
<IconX class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -128,9 +141,11 @@ export const ToastContainer = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="fixed top-4 right-4 z-50 space-y-4">
|
||||
<div class="pointer-events-none fixed left-1/2 top-4 z-50 flex w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 flex-col gap-3 sm:left-auto sm:right-4 sm:top-4 sm:w-full sm:translate-x-0">
|
||||
{toasts().map(toast => (
|
||||
<ToastItem toast={toast} onClose={handleClose} />
|
||||
<div class="pointer-events-auto">
|
||||
<ToastItem toast={toast} onClose={handleClose} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ModalPortal } from '@/components/ui/ModalPortal';
|
||||
import { For, Show, createEffect } from 'solid-js';
|
||||
import { Show } from 'solid-js';
|
||||
import { NoteContentRenderer } from '@/components/notes/NoteContentRenderer';
|
||||
import { IconX, IconEdit, IconPin, IconTrash, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
|
||||
|
||||
interface Note {
|
||||
@@ -36,34 +37,39 @@ interface ViewNoteModalProps {
|
||||
|
||||
export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
console.log('ViewNoteModal render:', { isOpen: props.isOpen, note: props.note?.title });
|
||||
|
||||
// Make the function available globally for checkbox onchange handlers
|
||||
createEffect(() => {
|
||||
(window as any).updateViewNoteContent = (checkbox: HTMLInputElement) => {
|
||||
if (props.note && props.onUpdateNote) {
|
||||
const lines = props.note.content.split('\n');
|
||||
let checkboxCount = 0;
|
||||
const checkboxElements = document.querySelectorAll('.note-content input[type="checkbox"]');
|
||||
const checkboxIndex = Array.from(checkboxElements).indexOf(checkbox);
|
||||
|
||||
const updatedLines = lines.map(line => {
|
||||
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
|
||||
const checkedMatch = line.match(/^- \[x\] (.*)$/);
|
||||
|
||||
if (uncheckedMatch || checkedMatch) {
|
||||
if (checkboxCount === checkboxIndex) {
|
||||
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
|
||||
return checkbox.checked ? `- [x] ${text}` : `- [ ] ${text}`;
|
||||
}
|
||||
checkboxCount++;
|
||||
}
|
||||
|
||||
const getNoteKind = () => {
|
||||
if (props.note?.isHtml) return 'html' as const;
|
||||
if (props.note?.isMarkdown) return 'markdown' as const;
|
||||
return 'plain' as const;
|
||||
};
|
||||
|
||||
const updateNoteCheckbox = (checkboxIndex: number, nextChecked: boolean) => {
|
||||
if (!props.note || !props.onUpdateNote) return;
|
||||
|
||||
let taskCounter = 0;
|
||||
const nextContent = props.note.content
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
|
||||
const checkedMatch = line.match(/^- \[(x|X)\] (.*)$/);
|
||||
if (!uncheckedMatch && !checkedMatch) {
|
||||
return line;
|
||||
});
|
||||
|
||||
props.onUpdateNote(props.note.id, updatedLines.join('\n'));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const currentIndex = taskCounter;
|
||||
taskCounter += 1;
|
||||
if (currentIndex !== checkboxIndex) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const label = uncheckedMatch?.[1] || checkedMatch?.[2] || '';
|
||||
return nextChecked ? `- [x] ${label}` : `- [ ] ${label}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
props.onUpdateNote(props.note.id, nextContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalPortal>
|
||||
@@ -185,52 +191,12 @@ export const ViewNoteModal = (props: ViewNoteModalProps) => {
|
||||
)}
|
||||
|
||||
{/* Note Content */}
|
||||
<div class="prose prose-invert max-w-none note-content">
|
||||
{props.note.isHtml ? (
|
||||
<div
|
||||
class="text-[#fafafa] leading-relaxed"
|
||||
innerHTML={props.note.content}
|
||||
/>
|
||||
) : props.note.isMarkdown ? (
|
||||
<div class="text-[#fafafa] leading-relaxed">
|
||||
{/* Enhanced Markdown rendering with image support */}
|
||||
<For each={props.note.content
|
||||
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mb-4">$1</h1>')
|
||||
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-bold mb-3">$1</h2>')
|
||||
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-bold mb-2">$1</h3>')
|
||||
.replace(/^#### (.*$)/gim, '<h4 class="text-md font-bold mb-2">$1</h4>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-sm">$1</code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-4 rounded mb-4 overflow-x-auto"><code class="text-sm">$2</code></pre>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" checked class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||
.replace(/\n\n/g, '</p><p class="mb-4">')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-4 italic text-[#aaa] mb-4">$1</blockquote>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="max-w-full h-auto rounded mb-4" onerror="this.style.display=\'none\'; this.nextElementSibling.style.display=\'block\';" /><div style="display:none;" class="text-[#666] italic mb-4">Image: $1 ($2)</div>')
|
||||
.split('</p><p class="mb-4">')}>
|
||||
{(line) => (
|
||||
<div innerHTML={line.startsWith('<') ? line : `<p class="mb-4">${line}</p>`} />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
) : (
|
||||
<div class="text-[#fafafa] whitespace-pre-wrap leading-relaxed">
|
||||
{/* Auto-detect and render URLs and basic formatting */}
|
||||
{props.note.content
|
||||
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-2"><input type="checkbox" checked class="rounded" onclick="this.checked=!this.checked" onchange="updateViewNoteContent(this)"><span>$1</span></div>')
|
||||
.split('\n').map((line) => (
|
||||
<div innerHTML={line || '<br />'} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div class="max-w-none note-content">
|
||||
<NoteContentRenderer
|
||||
content={props.note.content}
|
||||
kind={getNoteKind()}
|
||||
onToggleTask={props.onUpdateNote ? updateNoteCheckbox : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
|
||||
Vendored
-2
@@ -42,7 +42,6 @@ declare module "*.bmp" {
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_DEMO_MODE?: string;
|
||||
readonly VITE_OAUTH_SERVICE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
@@ -53,7 +52,6 @@ interface Window {
|
||||
importMetaEnv?: {
|
||||
VITE_API_URL?: string;
|
||||
VITE_DEMO_MODE?: string;
|
||||
VITE_OAUTH_SERVICE_URL?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ import 'uno.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// Clear demo mode data if demo mode is disabled
|
||||
import { isDemoMode, clearDemoMode } from './lib/demo-mode'
|
||||
|
||||
if (!isDemoMode()) {
|
||||
clearDemoMode()
|
||||
}
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
render(() => <App />, root!)
|
||||
|
||||
@@ -134,6 +134,7 @@ export const AuthProvider: ParentComponent = (props) => {
|
||||
localStorage.setItem('token', mockToken);
|
||||
localStorage.setItem('trackeep_user', JSON.stringify(mockUser));
|
||||
localStorage.setItem('user', JSON.stringify(mockUser));
|
||||
localStorage.setItem('theme', 'dark');
|
||||
|
||||
// Apply theme
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
|
||||
@@ -14,6 +14,15 @@ export const isEnvDemoMode = (): boolean => {
|
||||
const buildTimeResult = import.meta.env.VITE_DEMO_MODE === 'true';
|
||||
|
||||
const result = runtimeResult || importMetaEnvResult || buildTimeResult;
|
||||
|
||||
// Debug logging to help troubleshoot demo mode issues
|
||||
console.debug('Demo mode check:', {
|
||||
runtimeEnv: (window as any).ENV?.VITE_DEMO_MODE,
|
||||
importMetaEnv: (window as any).importMetaEnv?.VITE_DEMO_MODE,
|
||||
buildTimeEnv: import.meta.env.VITE_DEMO_MODE,
|
||||
finalResult: result
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -54,6 +63,15 @@ export const clearDemoMode = (): void => {
|
||||
localStorage.removeItem('trackeep_token');
|
||||
localStorage.removeItem('trackeep_user');
|
||||
}
|
||||
|
||||
// Clear any sessionStorage demo data
|
||||
const sessionToken = sessionStorage.getItem('token');
|
||||
if (sessionToken && sessionToken.includes('demo-token')) {
|
||||
sessionStorage.removeItem('token');
|
||||
sessionStorage.removeItem('user');
|
||||
}
|
||||
|
||||
console.debug('Demo mode data cleared from storage');
|
||||
};
|
||||
|
||||
// Set demo mode (no-op - environment variable only)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
export const getOAuthCallbackUrl = (): string => {
|
||||
export const getAuthCallbackUrl = (): string => {
|
||||
return new URL('/auth/callback', window.location.origin).toString();
|
||||
};
|
||||
|
||||
export const startGitHubOAuth = (): void => {
|
||||
export const startGitHubSignIn = (): void => {
|
||||
const apiBase = getApiV1BaseUrl();
|
||||
const frontendRedirect = getOAuthCallbackUrl();
|
||||
const frontendRedirect = getAuthCallbackUrl();
|
||||
|
||||
window.location.href =
|
||||
`${apiBase}/auth/github?frontend_redirect=${encodeURIComponent(frontendRedirect)}`;
|
||||
};
|
||||
|
||||
export const startGitHubOAuth = startGitHubSignIn;
|
||||
|
||||
@@ -211,19 +211,7 @@ export const AIChat = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Switcher */}
|
||||
<div class="flex items-center gap-3">
|
||||
<select
|
||||
value={selectedModel()}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
class="px-3 py-2 text-sm border border-border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="standard">Standard Model</option>
|
||||
<option value="advanced">Advanced Model</option>
|
||||
<option value="fast">Fast Model</option>
|
||||
<option value="creative">Creative Model</option>
|
||||
</select>
|
||||
|
||||
{/* View Switcher */}
|
||||
<div class="flex items-center gap-1 p-1 bg-muted rounded-lg">
|
||||
<button
|
||||
|
||||
@@ -35,6 +35,23 @@ interface Bookmark {
|
||||
screenshot_original?: string;
|
||||
}
|
||||
|
||||
interface VideoBookmarkTag {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface VideoBookmark {
|
||||
id: number;
|
||||
video_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
channel: string;
|
||||
thumbnail: string;
|
||||
url: string;
|
||||
duration: string;
|
||||
publishedAt: string;
|
||||
tags: VideoBookmarkTag[];
|
||||
}
|
||||
|
||||
export const Bookmarks = () => {
|
||||
const getBookmarkInitial = (title?: string) => {
|
||||
const safeTitle = typeof title === 'string' ? title.trim() : '';
|
||||
@@ -91,7 +108,7 @@ export const Bookmarks = () => {
|
||||
};
|
||||
|
||||
const [bookmarks, setBookmarks] = createSignal<Bookmark[]>([]);
|
||||
const [videoBookmarks, setVideoBookmarks] = createSignal<any[]>([]);
|
||||
const [videoBookmarks, setVideoBookmarks] = createSignal<VideoBookmark[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
const [isLoadingVideos, setIsLoadingVideos] = createSignal(true);
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
@@ -129,7 +146,7 @@ export const Bookmarks = () => {
|
||||
|
||||
// Load video bookmarks
|
||||
try {
|
||||
const videosResponse = await fetch(`${API_BASE_URL}/youtube/videos`, {
|
||||
const videosResponse = await fetch(`${API_BASE_URL}/video-bookmarks`, {
|
||||
headers: {
|
||||
'Authorization': localStorage.getItem('trackeep_token') ? `Bearer ${localStorage.getItem('trackeep_token')}` : '',
|
||||
},
|
||||
@@ -137,7 +154,8 @@ export const Bookmarks = () => {
|
||||
|
||||
if (videosResponse.ok) {
|
||||
const videosData = await videosResponse.json();
|
||||
setVideoBookmarks(Array.isArray(videosData) ? videosData : []);
|
||||
const items = Array.isArray(videosData?.bookmarks) ? videosData.bookmarks : [];
|
||||
setVideoBookmarks(items.map(adaptVideoBookmarkFromApi));
|
||||
} else {
|
||||
setVideoBookmarks([]);
|
||||
}
|
||||
@@ -335,24 +353,34 @@ export const Bookmarks = () => {
|
||||
|
||||
const handleVideoSubmit = async (video: any) => {
|
||||
try {
|
||||
// Use the YouTube API to add video
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8080'}/api/v1/youtube/video-details`, {
|
||||
const response = await fetch(`${API_BASE_URL}/video-bookmarks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''}`
|
||||
},
|
||||
body: JSON.stringify({ video_id: video.video_id })
|
||||
body: JSON.stringify({
|
||||
url: video.url,
|
||||
description: video.description,
|
||||
tags: Array.isArray(video.tags) ? video.tags.join(',') : '',
|
||||
is_favorite: false,
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('Video added:', video);
|
||||
const data = await response.json();
|
||||
const created = data?.bookmark ? adaptVideoBookmarkFromApi(data.bookmark) : null;
|
||||
if (created) {
|
||||
setVideoBookmarks(prev => [created, ...prev]);
|
||||
}
|
||||
} else {
|
||||
console.warn('Video save endpoint returned non-OK status');
|
||||
const error = await response.json().catch(() => ({}));
|
||||
throw new Error(error.error || 'Failed to save video bookmark');
|
||||
}
|
||||
setShowVideoModal(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to add video:', error);
|
||||
alert(error instanceof Error ? error.message : 'Failed to add video bookmark');
|
||||
setShowVideoModal(false);
|
||||
}
|
||||
};
|
||||
@@ -693,7 +721,20 @@ export const Bookmarks = () => {
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this video bookmark?')) {
|
||||
setVideoBookmarks(prev => prev.filter(v => v.id !== video.id));
|
||||
fetch(`${API_BASE_URL}/video-bookmarks/${video.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('trackeep_token') || localStorage.getItem('token') || ''}`
|
||||
}
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete video bookmark');
|
||||
}
|
||||
setVideoBookmarks(prev => prev.filter(v => v.id !== video.id));
|
||||
}).catch((error) => {
|
||||
console.error('Failed to delete video bookmark:', error);
|
||||
alert(error instanceof Error ? error.message : 'Failed to delete video bookmark');
|
||||
});
|
||||
}
|
||||
}}
|
||||
icon={IconTrash}
|
||||
@@ -727,3 +768,23 @@ export const Bookmarks = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const adaptVideoBookmarkFromApi = (raw: any): VideoBookmark => {
|
||||
const rawTags = typeof raw?.tags === 'string'
|
||||
? raw.tags.split(',').map((tag: string) => tag.trim()).filter(Boolean)
|
||||
: Array.isArray(raw?.tags)
|
||||
? raw.tags.map((tag: any) => typeof tag === 'string' ? tag : tag?.name).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
video_id: raw.video_id,
|
||||
title: raw.title || raw.url || 'Untitled video',
|
||||
description: raw.description || '',
|
||||
channel: raw.channel || 'Unknown channel',
|
||||
thumbnail: raw.thumbnail || '',
|
||||
url: raw.url,
|
||||
duration: raw.duration || 'Unknown',
|
||||
publishedAt: raw.published_at || raw.created_at || 'Unknown',
|
||||
tags: rawTags.map((name: string) => ({ name })),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from '../components/ui/Button';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { toast } from '../components/ui/Toast';
|
||||
import { CheckCircle, AlertCircle, Shield, Key, Globe, Clock, Users, Settings } from 'lucide-solid';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
interface APIKey {
|
||||
id: number;
|
||||
@@ -41,6 +42,7 @@ interface Example {
|
||||
}
|
||||
|
||||
const BrowserExtensionSettings = () => {
|
||||
const apiBaseUrl = getApiV1BaseUrl();
|
||||
const [apiKeys, setApiKeys] = createSignal<APIKey[]>([]);
|
||||
const [extensions, setExtensions] = createSignal<BrowserExtension[]>([]);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
@@ -175,7 +177,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/browser-extension/api-keys', {
|
||||
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
@@ -194,7 +196,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
|
||||
const loadExtensions = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/browser-extension/extensions', {
|
||||
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
@@ -224,7 +226,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/browser-extension/api-keys/generate', {
|
||||
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -261,7 +263,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/browser-extension/api-keys/${keyId}`, {
|
||||
const response = await fetch(`${apiBaseUrl}/browser-extension/api-keys/${keyId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
@@ -286,7 +288,7 @@ curl -X POST \\\n -H "Authorization: Bearer tk_your_api_key_here" \\\n -H "Con
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/browser-extension/extensions/${extensionId}`, {
|
||||
const response = await fetch(`${apiBaseUrl}/browser-extension/extensions/${extensionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
|
||||
@@ -127,6 +127,36 @@ interface GitHubActivityEvent {
|
||||
type: 'push' | 'commit' | 'bookmark' | 'note';
|
||||
}
|
||||
|
||||
const demoGithubActivityEvents: GitHubActivityEvent[] = [
|
||||
{
|
||||
id: 'github-demo-1',
|
||||
title: 'Pushed 4 commits to trackeep/frontend',
|
||||
date: '2 hours ago',
|
||||
repo: 'trackeep',
|
||||
action: 'pushed',
|
||||
link: 'https://github.com/tdvorak/trackeep',
|
||||
type: 'push',
|
||||
},
|
||||
{
|
||||
id: 'github-demo-2',
|
||||
title: 'Commit: refine demo-mode note rendering',
|
||||
date: 'Yesterday',
|
||||
repo: 'trackeep',
|
||||
action: 'committed',
|
||||
link: 'https://github.com/tdvorak/trackeep',
|
||||
type: 'commit',
|
||||
},
|
||||
{
|
||||
id: 'github-demo-3',
|
||||
title: 'Pushed 2 commits to docker-homelab',
|
||||
date: '3 days ago',
|
||||
repo: 'docker-homelab',
|
||||
action: 'pushed',
|
||||
link: 'https://github.com/tdvorak/docker-homelab',
|
||||
type: 'push',
|
||||
},
|
||||
];
|
||||
|
||||
const createEmptyStats = (): QuickStats => ({
|
||||
totalDocuments: 0,
|
||||
totalBookmarks: 0,
|
||||
@@ -268,7 +298,7 @@ export const Dashboard = () => {
|
||||
if (isDemoMode()) {
|
||||
setDashboardStats(getMockStats());
|
||||
setDocuments(getMockDocuments());
|
||||
setGithubActivityEvents([]);
|
||||
setGithubActivityEvents(demoGithubActivityEvents);
|
||||
|
||||
const mockActivities = getMockActivities();
|
||||
const filteredActivities = mockActivities
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FileUpload } from '@/components/ui/FileUpload';
|
||||
import { FilePreviewModal } from '@/components/ui/FilePreviewModal';
|
||||
import { getFileTypeConfig, formatFileSize, getFileCategoryColor } from '@/utils/fileTypes';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { startGitHubOAuth } from '@/lib/oauth';
|
||||
import { startGitHubSignIn } from '@/lib/oauth';
|
||||
import { isDemoMode } from '@/lib/demo-mode';
|
||||
import { getMockDocuments } from '@/lib/mockData';
|
||||
import {
|
||||
@@ -78,6 +78,7 @@ interface GitHubAppInstallation {
|
||||
interface GitHubAppStatus {
|
||||
app_slug: string;
|
||||
install_enabled: boolean;
|
||||
sign_in_configured: boolean;
|
||||
credentials_configured: boolean;
|
||||
installed: boolean;
|
||||
installation?: GitHubAppInstallation;
|
||||
@@ -104,6 +105,7 @@ type FilesTab = 'files' | 'github-backups';
|
||||
const defaultGitHubAppStatus: GitHubAppStatus = {
|
||||
app_slug: '',
|
||||
install_enabled: false,
|
||||
sign_in_configured: false,
|
||||
credentials_configured: false,
|
||||
installed: false,
|
||||
};
|
||||
@@ -258,12 +260,12 @@ export const Files = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
let oauthConnected = false;
|
||||
let githubSignInConnected = false;
|
||||
const meResponse = await fetchWithAuth('/auth/me');
|
||||
if (meResponse.ok) {
|
||||
const meData = await meResponse.json();
|
||||
oauthConnected = Boolean(meData?.user?.github_id);
|
||||
setGitHubOAuthConnected(oauthConnected);
|
||||
githubSignInConnected = Boolean(meData?.user?.github_id);
|
||||
setGitHubOAuthConnected(githubSignInConnected);
|
||||
setGitHubUsername(typeof meData?.user?.username === 'string' ? meData.user.username : '');
|
||||
} else {
|
||||
setGitHubOAuthConnected(false);
|
||||
@@ -276,6 +278,7 @@ export const Files = () => {
|
||||
setGitHubAppStatus({
|
||||
app_slug: appStatusData.app_slug || '',
|
||||
install_enabled: Boolean(appStatusData.install_enabled),
|
||||
sign_in_configured: Boolean(appStatusData.sign_in_configured),
|
||||
credentials_configured: Boolean(appStatusData.credentials_configured),
|
||||
installed: Boolean(appStatusData.installed),
|
||||
installation: appStatusData.installation,
|
||||
@@ -296,7 +299,7 @@ export const Files = () => {
|
||||
}
|
||||
|
||||
let reposResponse: Response | null = null;
|
||||
if (oauthConnected) {
|
||||
if (githubSignInConnected) {
|
||||
reposResponse = await fetchWithAuth('/github/repos');
|
||||
} else if (gitHubAppStatus().installed && gitHubAppStatus().credentials_configured) {
|
||||
reposResponse = await fetchWithAuth('/github/app/repos');
|
||||
@@ -352,7 +355,7 @@ export const Files = () => {
|
||||
setGitHubError('');
|
||||
setGitHubMessage('');
|
||||
|
||||
const source = gitHubOAuthConnected() ? 'oauth' : 'github_app';
|
||||
const source = gitHubOAuthConnected() ? 'github_user' : 'github_app';
|
||||
const response = await fetchWithAuth('/github/backups', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -506,11 +509,11 @@ export const Files = () => {
|
||||
const selectAllRepos = () => setSelectedRepos(gitHubRepos().map(repo => repo.full_name));
|
||||
const clearSelectedRepos = () => setSelectedRepos([]);
|
||||
|
||||
const formatSourceLabel = (source: string) => source === 'github_app' ? 'GitHub App' : 'OAuth';
|
||||
const formatSourceLabel = (source: string) => source === 'github_app' ? 'GitHub App' : 'GitHub Sign-In';
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-foreground">Files</h1>
|
||||
<p class="text-muted-foreground mt-1">
|
||||
@@ -518,7 +521,12 @@ export const Files = () => {
|
||||
</p>
|
||||
</div>
|
||||
<Show when={activeTab() === 'files'}>
|
||||
<Button onClick={() => setShowUploadModal(true)} haptic="impact">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowUploadModal(true);
|
||||
haptics.impact();
|
||||
}}
|
||||
>
|
||||
<IconUpload class="size-4 mr-2" />
|
||||
Upload File
|
||||
</Button>
|
||||
@@ -743,11 +751,10 @@ export const Files = () => {
|
||||
|
||||
<Show when={activeTab() === 'github-backups'}>
|
||||
<div class="space-y-6">
|
||||
<Card class="relative overflow-hidden p-6 border border-primary/25 bg-gradient-to-r from-primary/12 via-primary/5 to-transparent">
|
||||
<div class="absolute -top-8 -right-8 h-32 w-32 rounded-full bg-primary/15 blur-2xl" />
|
||||
<div class="relative z-10 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<Card class="p-6 border border-border bg-card">
|
||||
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm uppercase tracking-wide text-primary/80 font-semibold mb-2">Repository Storage</p>
|
||||
<p class="text-sm uppercase tracking-wide text-muted-foreground font-semibold mb-2">Repository Storage</p>
|
||||
<h2 class="text-2xl font-bold text-foreground mb-1">GitHub Backups</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Select repositories and store mirrored backups locally for resilient archival.
|
||||
@@ -763,7 +770,7 @@ export const Files = () => {
|
||||
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-500'
|
||||
: 'border-border bg-muted text-muted-foreground'
|
||||
}`}>
|
||||
OAuth: {gitHubOAuthConnected() ? 'Connected' : 'Disconnected'}
|
||||
GitHub Sign-In: {gitHubOAuthConnected() ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
<span class={`px-2.5 py-1 rounded-full border ${
|
||||
gitHubAppStatus().installed
|
||||
@@ -786,16 +793,26 @@ export const Files = () => {
|
||||
<IconRefresh class="size-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => startGitHubOAuth()}>
|
||||
<Button variant="outline" onClick={() => startGitHubSignIn()} disabled={!gitHubAppStatus().sign_in_configured}>
|
||||
<IconBrandGithub class="size-4 mr-2" />
|
||||
Connect OAuth
|
||||
Connect GitHub Sign-In
|
||||
</Button>
|
||||
<Button onClick={() => handleInstallGitHubApp()} disabled={isGitHubActionLoading()}>
|
||||
<Button onClick={() => handleInstallGitHubApp()} disabled={isGitHubActionLoading() || !gitHubOAuthConnected()}>
|
||||
<IconGitFork class="size-4 mr-2" />
|
||||
Install GitHub App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={!gitHubAppStatus().sign_in_configured}>
|
||||
<p class="text-xs text-amber-500 mt-3">
|
||||
GitHub sign-in is not available from the unified Trackeep control service.
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={gitHubAppStatus().sign_in_configured && !gitHubOAuthConnected()}>
|
||||
<p class="text-xs text-muted-foreground mt-3">
|
||||
Sign in with GitHub first, then install the GitHub App to verify the installation against your account.
|
||||
</p>
|
||||
</Show>
|
||||
</Card>
|
||||
|
||||
<Show when={gitHubMessage()}>
|
||||
@@ -869,11 +886,10 @@ export const Files = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={gitHubRepos().length > 0} fallback={
|
||||
<div class="rounded-xl border border-dashed border-border p-8 text-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
No repositories available. Connect OAuth or install/configure GitHub App access first.
|
||||
No repositories available. Connect GitHub sign-in or install/configure GitHub App access first.
|
||||
</p>
|
||||
</div>
|
||||
}>
|
||||
|
||||
+209
-56
@@ -3,7 +3,7 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { GitHubActivity } from '@/components/ui/GitHubActivity';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { startGitHubOAuth } from '@/lib/oauth';
|
||||
import { startGitHubSignIn } from '@/lib/oauth';
|
||||
import {
|
||||
IconBrandGithub,
|
||||
IconTrendingUp,
|
||||
@@ -43,6 +43,7 @@ interface GitHubAppInstallation {
|
||||
interface GitHubAppStatus {
|
||||
app_slug: string;
|
||||
install_enabled: boolean;
|
||||
sign_in_configured: boolean;
|
||||
credentials_configured: boolean;
|
||||
installed: boolean;
|
||||
installation?: GitHubAppInstallation;
|
||||
@@ -68,6 +69,16 @@ interface GitHubBackupResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface GitHubActivityResponse {
|
||||
contributions: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
level: number;
|
||||
}>;
|
||||
weekly_data: number[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
interface GitHubStats {
|
||||
totalRepos: number;
|
||||
totalStars: number;
|
||||
@@ -107,6 +118,7 @@ export const GitHub = () => {
|
||||
const [appStatus, setAppStatus] = createSignal<GitHubAppStatus>({
|
||||
app_slug: '',
|
||||
install_enabled: false,
|
||||
sign_in_configured: false,
|
||||
credentials_configured: false,
|
||||
installed: false
|
||||
});
|
||||
@@ -117,10 +129,38 @@ export const GitHub = () => {
|
||||
const [isInstallingApp, setIsInstallingApp] = createSignal(false);
|
||||
const [backupMessage, setBackupMessage] = createSignal('');
|
||||
const [backupError, setBackupError] = createSignal('');
|
||||
const [selectedLanguage, setSelectedLanguage] = createSignal<string>('');
|
||||
const [searchTerm, setSearchTerm] = createSignal<string>('');
|
||||
|
||||
const weeklyTotal = () => weeklyActivity().reduce((a, b) => a + b, 0);
|
||||
const selectedCount = () => selectedRepos().length;
|
||||
|
||||
const filteredRepos = () => {
|
||||
let repos = githubStats().repos;
|
||||
|
||||
// Filter by language
|
||||
if (selectedLanguage()) {
|
||||
repos = repos.filter(repo => repo.language === selectedLanguage());
|
||||
}
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm()) {
|
||||
const term = searchTerm().toLowerCase();
|
||||
repos = repos.filter(repo =>
|
||||
repo.name.toLowerCase().includes(term) ||
|
||||
repo.description?.toLowerCase().includes(term) ||
|
||||
repo.language?.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return repos;
|
||||
};
|
||||
|
||||
const uniqueLanguages = () => {
|
||||
const languages = new Set(githubStats().repos.map(repo => repo.language).filter(Boolean));
|
||||
return Array.from(languages).sort();
|
||||
};
|
||||
|
||||
const getAuthToken = () => {
|
||||
return localStorage.getItem('trackeep_token') || localStorage.getItem('token') || '';
|
||||
};
|
||||
@@ -231,6 +271,7 @@ export const GitHub = () => {
|
||||
setAppStatus({
|
||||
app_slug: data.app_slug || '',
|
||||
install_enabled: Boolean(data.install_enabled),
|
||||
sign_in_configured: Boolean(data.sign_in_configured),
|
||||
credentials_configured: Boolean(data.credentials_configured),
|
||||
installed: Boolean(data.installed),
|
||||
installation: data.installation
|
||||
@@ -293,6 +334,39 @@ export const GitHub = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubActivity = async () => {
|
||||
try {
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/github/activity`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json() as GitHubActivityResponse;
|
||||
setWeeklyActivity(data.weekly_data);
|
||||
|
||||
// Update GitHubActivity component with real data
|
||||
updateGitHubActivityComponent(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub activity:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateGitHubActivityComponent = (data: GitHubActivityResponse) => {
|
||||
// This will be used to update the GitHubActivity component
|
||||
// For now, we'll store the data and let the component pick it up
|
||||
const event = new CustomEvent('githubActivityData', { detail: data });
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const fetchGitHubStats = async () => {
|
||||
const loaded = await fetchRepos('/github/repos');
|
||||
if (!loaded) {
|
||||
@@ -316,6 +390,7 @@ export const GitHub = () => {
|
||||
setAppStatus({
|
||||
app_slug: '',
|
||||
install_enabled: false,
|
||||
sign_in_configured: false,
|
||||
credentials_configured: false,
|
||||
installed: false
|
||||
});
|
||||
@@ -341,17 +416,19 @@ export const GitHub = () => {
|
||||
}
|
||||
|
||||
const userData = await response.json();
|
||||
const hasOAuthConnection = Boolean(userData?.user?.github_id);
|
||||
const hasGitHubSignIn = Boolean(userData?.user?.github_id);
|
||||
setUsername(typeof userData?.user?.username === 'string' ? userData.user.username : '');
|
||||
setIsConnected(hasOAuthConnection);
|
||||
setIsConnected(hasGitHubSignIn);
|
||||
|
||||
if (hasOAuthConnection) {
|
||||
if (hasGitHubSignIn) {
|
||||
await fetchGitHubStats();
|
||||
await fetchGitHubActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
if (appInfo?.installed && appInfo.credentials_configured) {
|
||||
await fetchGitHubAppRepos();
|
||||
await fetchGitHubActivity();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -363,7 +440,7 @@ export const GitHub = () => {
|
||||
};
|
||||
|
||||
const connectGitHub = () => {
|
||||
startGitHubOAuth();
|
||||
startGitHubSignIn();
|
||||
};
|
||||
|
||||
const installGitHubApp = async () => {
|
||||
@@ -427,7 +504,7 @@ export const GitHub = () => {
|
||||
setBackupMessage('');
|
||||
setBackupError('');
|
||||
|
||||
const source = appStatus().installed ? 'github_app' : 'oauth';
|
||||
const source = appStatus().installed ? 'github_app' : 'github_user';
|
||||
const response = await fetch(`${API_BASE_URL}/github/backups`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -484,6 +561,38 @@ export const GitHub = () => {
|
||||
return 'hsl(var(--primary))';
|
||||
};
|
||||
|
||||
const getLanguageIcon = (language: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'Go': '🐹',
|
||||
'TypeScript': '🔷',
|
||||
'JavaScript': '🟨',
|
||||
'CSS': '🎨',
|
||||
'HTML': '🌐',
|
||||
'Python': '🐍',
|
||||
'Dart': '🎯',
|
||||
'C++': '⚙️',
|
||||
'MDX': '📝',
|
||||
'Shell': '🐚',
|
||||
'Rust': '🦀',
|
||||
'Java': '☕',
|
||||
'Ruby': '💎',
|
||||
'PHP': '🐘',
|
||||
'Swift': '🍎',
|
||||
'Kotlin': '🎯',
|
||||
'Vue': '💚',
|
||||
'React': '⚛️',
|
||||
'Angular': '🔺',
|
||||
'Svelte': '🔥',
|
||||
'Docker': '🐳',
|
||||
'YAML': '📄',
|
||||
'JSON': '📋',
|
||||
'Markdown': '📝',
|
||||
'SQL': '🗃️',
|
||||
'GraphQL': '◈'
|
||||
};
|
||||
return iconMap[language] || '📄';
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 space-y-6 overflow-x-hidden max-w-full">
|
||||
{/* Header */}
|
||||
@@ -511,9 +620,9 @@ export const GitHub = () => {
|
||||
<Button onClick={() => {
|
||||
connectGitHub();
|
||||
haptics.impact();
|
||||
}}>
|
||||
}} disabled={!appStatus().sign_in_configured}>
|
||||
<IconBrandGithub class="size-4 mr-2" />
|
||||
Connect GitHub
|
||||
Connect GitHub Sign-In
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -528,52 +637,51 @@ export const GitHub = () => {
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{isConnected() ? `Connected via OAuth as @${username()}` : `Connected via GitHub App as @${username()}`}
|
||||
{isConnected() ? `Connected via GitHub App sign-in as @${username()}` : `Connected via GitHub App installation as @${username()}`}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{isConnected() ? 'Syncing data from GitHub OAuth API' : 'Syncing data from GitHub App installation'}
|
||||
{isConnected() ? 'Syncing user data with your GitHub App sign-in token' : 'Syncing repositories from the GitHub App installation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* GitHub App Status */}
|
||||
<Card class="p-4">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">GitHub App Backup Access</p>
|
||||
{appStatus().install_enabled ? (
|
||||
{/* GitHub App Status - Only show if not installed */}
|
||||
{!appStatus().installed && appStatus().install_enabled && (
|
||||
<Card class="p-4">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">GitHub App Backup Access</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{appStatus().installed
|
||||
? `Installed${appStatus().installation?.account_login ? ` for ${appStatus().installation?.account_login}` : ''}`
|
||||
: 'Not installed yet'}
|
||||
Not installed yet
|
||||
</p>
|
||||
) : (
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
GitHub App install is not configured on this server.
|
||||
</p>
|
||||
)}
|
||||
{appStatus().install_enabled && !appStatus().credentials_configured && (
|
||||
<p class="text-xs text-amber-500 mt-1">
|
||||
App credentials are missing (`GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`).
|
||||
</p>
|
||||
)}
|
||||
{!appStatus().sign_in_configured && (
|
||||
<p class="text-xs text-amber-500 mt-1">
|
||||
GitHub sign-in is not available from the unified Trackeep control service.
|
||||
</p>
|
||||
)}
|
||||
{appStatus().install_enabled && !appStatus().credentials_configured && (
|
||||
<p class="text-xs text-amber-500 mt-1">
|
||||
Unified GitHub App credentials are not configured on `hq.trackeep.org`.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={() => fetchGitHubAppStatus()}>
|
||||
Refresh App Status
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!appStatus().install_enabled || isInstallingApp() || !isConnected()}
|
||||
onClick={() => installGitHubApp()}
|
||||
>
|
||||
{isInstallingApp() ? 'Opening...' : 'Install GitHub App'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" onClick={() => fetchGitHubAppStatus()}>
|
||||
Refresh App Status
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!appStatus().install_enabled || isInstallingApp()}
|
||||
onClick={() => installGitHubApp()}
|
||||
>
|
||||
{isInstallingApp() ? 'Opening...' : (appStatus().installed ? 'Reinstall GitHub App' : 'Install GitHub App')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stats Overview - 2-column layout with larger left column */}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -671,8 +779,9 @@ export const GitHub = () => {
|
||||
) : (
|
||||
<div class="space-y-3">
|
||||
{githubStats().languages.map((language) => (
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between p-2 rounded-lg hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-lg">{getLanguageIcon(language.name)}</span>
|
||||
<div
|
||||
class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={`background-color: ${language.color}`}
|
||||
@@ -790,6 +899,45 @@ export const GitHub = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{githubStats().repos.length > 0 && (
|
||||
<div class="flex flex-col md:flex-row gap-3 mb-4 p-3 bg-muted/30 rounded-lg">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search repositories..."
|
||||
class="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
value={searchTerm()}
|
||||
onInput={(e) => setSearchTerm(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
class="px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
value={selectedLanguage()}
|
||||
onChange={(e) => setSelectedLanguage(e.currentTarget.value)}
|
||||
>
|
||||
<option value="">All Languages</option>
|
||||
{uniqueLanguages().map(lang => (
|
||||
<option value={lang}>{lang}</option>
|
||||
))}
|
||||
</select>
|
||||
{(selectedLanguage() || searchTerm()) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedLanguage('');
|
||||
setSearchTerm('');
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backupMessage() && (
|
||||
<p class="text-sm text-emerald-500 mb-3">{backupMessage()}</p>
|
||||
)}
|
||||
@@ -819,23 +967,25 @@ export const GitHub = () => {
|
||||
<p class="text-sm text-muted-foreground">No repositories available yet.</p>
|
||||
) : (
|
||||
<div class="space-y-4">
|
||||
{githubStats().repos.map((repo) => (
|
||||
<div class="border border-border rounded-lg p-4">
|
||||
{filteredRepos().map((repo) => (
|
||||
<div class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-colors cursor-pointer group">
|
||||
<div class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border accent-primary"
|
||||
checked={selectedRepos().includes(repo.full_name)}
|
||||
onChange={() => toggleRepoSelection(repo.full_name)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="text-lg font-medium text-foreground">{repo.name}</h4>
|
||||
<h4 class="text-lg font-medium text-foreground group-hover:text-primary transition-colors">{repo.name}</h4>
|
||||
{repo.language && (
|
||||
<span
|
||||
class="text-xs px-2 py-1 rounded-full"
|
||||
class="text-xs px-2 py-1 rounded-full flex items-center gap-1"
|
||||
style={`background-color: ${getLanguageColor()}20; color: ${getLanguageColor()}`}
|
||||
>
|
||||
<span>{getLanguageIcon(repo.language)}</span>
|
||||
{repo.language}
|
||||
</span>
|
||||
)}
|
||||
@@ -857,14 +1007,17 @@ export const GitHub = () => {
|
||||
<span>Updated {formatDate(repo.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(repo.html_url, '_blank', 'noopener,noreferrer')}
|
||||
aria-label={`Open ${repo.full_name}`}
|
||||
>
|
||||
<IconExternalLink class="size-4" />
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(repo.html_url, '_blank', 'noopener,noreferrer')}
|
||||
aria-label={`Open ${repo.full_name}`}
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<IconExternalLink class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createSignal, onMount } from 'solid-js';
|
||||
import { useAuth, type LoginRequest, type RegisterRequest } from '@/lib/auth';
|
||||
import { isEnvDemoMode } from '@/lib/demo-mode';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { startGitHubOAuth } from '@/lib/oauth';
|
||||
import { startGitHubSignIn } from '@/lib/oauth';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { IconBrandGithub } from '@tabler/icons-solidjs';
|
||||
|
||||
@@ -15,6 +15,14 @@ interface LoginFormData {
|
||||
fullName: string;
|
||||
}
|
||||
|
||||
function getSafeNextPath(): string {
|
||||
const next = new URLSearchParams(window.location.search).get('next') || '';
|
||||
if (!next.startsWith('/') || next.startsWith('//')) {
|
||||
return '/app';
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export const Login = () => {
|
||||
const { login, register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -26,6 +34,7 @@ export const Login = () => {
|
||||
fullName: '',
|
||||
});
|
||||
const [error, setError] = createSignal('');
|
||||
const [setupError, setSetupError] = createSignal('');
|
||||
const [noAccountsExist, setNoAccountsExist] = createSignal(false);
|
||||
const [registrationDisabled, setRegistrationDisabled] = createSignal(false);
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
@@ -57,6 +66,7 @@ export const Login = () => {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSetupError('');
|
||||
const data = await response.json();
|
||||
if (data.hasUsers) {
|
||||
// Users exist - disable registration
|
||||
@@ -84,15 +94,27 @@ export const Login = () => {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Default to login mode if backend returns error
|
||||
let backendMessage = 'Unable to determine whether this Trackeep instance has been initialized. Check the backend logs and database schema.';
|
||||
try {
|
||||
const data = await response.json();
|
||||
if (typeof data?.error === 'string' && data.error.trim() !== '') {
|
||||
backendMessage = `${backendMessage} Backend error: ${data.error}`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parsing errors and fall back to the generic message.
|
||||
}
|
||||
|
||||
setSetupError(backendMessage);
|
||||
setIsLogin(true);
|
||||
setRegistrationDisabled(true);
|
||||
setRegistrationDisabled(false);
|
||||
setNoAccountsExist(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to check if users exist:', err);
|
||||
// Default to login mode if backend is unavailable
|
||||
setSetupError('Unable to reach the backend to determine whether any accounts exist yet.');
|
||||
setIsLogin(true);
|
||||
setRegistrationDisabled(true);
|
||||
setRegistrationDisabled(false);
|
||||
setNoAccountsExist(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,8 +139,7 @@ export const Login = () => {
|
||||
};
|
||||
await register(registerPayload);
|
||||
}
|
||||
// Navigate to app after successful login/registration
|
||||
navigate('/app');
|
||||
navigate(getSafeNextPath(), { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
@@ -189,6 +210,16 @@ export const Login = () => {
|
||||
) : (
|
||||
<>
|
||||
{/* Registration disabled message */}
|
||||
{setupError() && (
|
||||
<div class="mb-6 bg-amber-500/10 border border-amber-500/50 text-amber-300 px-4 py-3 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="w-2 h-2 bg-amber-400 rounded-full"></span>
|
||||
<span class="font-medium">Setup Check Failed</span>
|
||||
</div>
|
||||
<p class="text-xs">{setupError()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registrationDisabled() && (
|
||||
<div class="mb-6 bg-blue-500/10 border border-blue-500/50 text-blue-400 px-4 py-3 rounded">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@@ -298,11 +329,11 @@ export const Login = () => {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGitHubOAuth}
|
||||
onClick={startGitHubSignIn}
|
||||
class="w-full flex items-center justify-center gap-2 bg-[#0f0f10] text-[#fafafa] py-2 px-4 rounded-md border border-[#262626] hover:border-[#39b9ff]/50 hover:bg-[#18181b] focus:outline-none focus:ring-2 focus:ring-[#39b9ff] focus:ring-offset-2 focus:ring-offset-[#141415] transition-colors"
|
||||
>
|
||||
<IconBrandGithub class="size-4" />
|
||||
Continue with GitHub
|
||||
Continue with GitHub App
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
+185
-39
@@ -1,36 +1,61 @@
|
||||
/* Modern Messages Styling - Matching Chat.tsx Design System */
|
||||
|
||||
/* Main container with modern backdrop */
|
||||
/* Full-screen shell aligned with the AI chat layout */
|
||||
.messages-shell {
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 70%, hsl(var(--muted) / 0.18) 100%);
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, hsl(var(--primary) / 0.08), transparent 28rem),
|
||||
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--background)) 100%);
|
||||
}
|
||||
|
||||
/* Screen management */
|
||||
.messages-shell-list .messages-main {
|
||||
display: none;
|
||||
.messages-alert-wrap {
|
||||
padding: 1rem 1rem 0;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.messages-shell-conversation .messages-sidebar {
|
||||
display: none;
|
||||
.messages-spotlight-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
padding: 0.9rem 1rem;
|
||||
box-shadow: 0 18px 50px -32px hsl(0 0% 0% / 0.85);
|
||||
}
|
||||
|
||||
.messages-spotlight-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.messages-spotlight-error {
|
||||
background: linear-gradient(135deg, hsl(var(--destructive) / 0.14), hsl(var(--background)));
|
||||
border-color: hsl(var(--destructive) / 0.3);
|
||||
}
|
||||
|
||||
.messages-spotlight-warning {
|
||||
background: linear-gradient(135deg, hsl(38 92% 50% / 0.16), hsl(var(--background)));
|
||||
border-color: hsl(38 92% 50% / 0.25);
|
||||
}
|
||||
|
||||
.messages-spotlight-info {
|
||||
background: linear-gradient(135deg, hsl(var(--primary) / 0.16), hsl(var(--background)));
|
||||
border-color: hsl(var(--primary) / 0.25);
|
||||
}
|
||||
|
||||
/* Modern sidebar styling */
|
||||
.messages-sidebar {
|
||||
border-inline: 1px solid hsl(var(--border));
|
||||
width: 20rem;
|
||||
max-width: 22rem;
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
width: min(100%, 980px);
|
||||
margin: 0 auto;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.messages-sidebar-header {
|
||||
padding: 1.5rem;
|
||||
padding: 1rem 1rem 0.9rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card) / 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
@@ -85,34 +110,43 @@
|
||||
|
||||
/* Modern conversation items */
|
||||
.conversation-item {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(var(--border) / 0.55);
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.95rem 1rem;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: hsl(var(--muted) / 0.6);
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
border-color: hsl(var(--primary) / 0.18);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px hsl(0 0% 0% / 0.08);
|
||||
box-shadow: 0 16px 30px -26px hsl(0 0% 0% / 0.85);
|
||||
}
|
||||
|
||||
.conversation-item-active {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary) / 0.2);
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.08);
|
||||
background: linear-gradient(135deg, hsl(var(--primary) / 0.14), hsl(var(--background)));
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.conversation-item-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-item-heading,
|
||||
.conversation-item-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.conversation-item-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
@@ -129,6 +163,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conversation-item-unread {
|
||||
@@ -146,24 +181,78 @@
|
||||
box-shadow: 0 2px 4px hsl(var(--primary) / 0.2);
|
||||
}
|
||||
|
||||
.conversation-item-avatar {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid hsl(var(--border) / 0.35);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.conversation-item-avatar-dm {
|
||||
background: linear-gradient(135deg, hsl(var(--primary) / 0.18), hsl(var(--primary) / 0.08));
|
||||
}
|
||||
|
||||
.conversation-item-avatar-group {
|
||||
background: linear-gradient(135deg, hsl(216 92% 60% / 0.22), hsl(192 92% 55% / 0.12));
|
||||
}
|
||||
|
||||
.conversation-item-avatar-team {
|
||||
background: linear-gradient(135deg, hsl(158 75% 42% / 0.22), hsl(143 70% 52% / 0.12));
|
||||
}
|
||||
|
||||
.conversation-item-avatar-global {
|
||||
background: linear-gradient(135deg, hsl(38 92% 56% / 0.24), hsl(16 92% 60% / 0.12));
|
||||
}
|
||||
|
||||
.conversation-item-avatar-self {
|
||||
background: linear-gradient(135deg, hsl(280 70% 60% / 0.2), hsl(324 70% 60% / 0.12));
|
||||
}
|
||||
|
||||
.conversation-item-avatar-vault {
|
||||
background: linear-gradient(135deg, hsl(0 0% 100% / 0.08), hsl(0 0% 100% / 0.02));
|
||||
}
|
||||
|
||||
.conversation-item-avatar-default {
|
||||
background: linear-gradient(135deg, hsl(var(--muted)), hsl(var(--muted) / 0.7));
|
||||
}
|
||||
|
||||
.conversation-item-time {
|
||||
font-size: 0.7rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-item-badge {
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Modern main conversation area */
|
||||
.messages-main {
|
||||
width: min(100%, 1180px);
|
||||
margin: 0 auto;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
|
||||
border-inline: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card));
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.messages-main-header {
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
padding: 1.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -214,15 +303,51 @@
|
||||
.messages-main-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.messages-main-empty-card {
|
||||
width: min(28rem, 100%);
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 1.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background:
|
||||
radial-gradient(circle at top, hsl(var(--primary) / 0.12), transparent 65%),
|
||||
hsl(var(--card) / 0.96);
|
||||
box-shadow: 0 28px 80px -44px hsl(0 0% 0% / 0.7);
|
||||
}
|
||||
|
||||
.messages-main-empty-orb {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 1.2rem;
|
||||
margin: 0 auto 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, hsl(var(--primary) / 0.18), hsl(var(--primary) / 0.06));
|
||||
border: 1px solid hsl(var(--primary) / 0.18);
|
||||
}
|
||||
|
||||
.messages-main-empty-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.messages-main-empty-copy {
|
||||
margin: 0.65rem auto 1.25rem;
|
||||
max-width: 22rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Modern call and transcript strips */
|
||||
.messages-call-strip,
|
||||
.messages-transcript-preview {
|
||||
padding: 1rem 1.5rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
@@ -239,7 +364,8 @@
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
background: hsl(var(--background));
|
||||
background:
|
||||
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--muted) / 0.14) 100%);
|
||||
}
|
||||
|
||||
.message-row {
|
||||
@@ -478,6 +604,26 @@
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.messages-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.messages-sidebar,
|
||||
.messages-main {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.messages-shell-list .messages-main {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages-shell-conversation .messages-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.messages-composer-drag {
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
border-color: hsl(var(--primary) / 0.2);
|
||||
|
||||
+255
-12
@@ -157,6 +157,19 @@ const REFERENCE_TYPE_OPTIONS = [
|
||||
|
||||
type TriStateFilter = 'any' | 'yes' | 'no';
|
||||
type CallStatus = 'idle' | 'starting' | 'calling' | 'in_call' | 'error';
|
||||
type SpotlightAlertTone = 'error' | 'warning' | 'info';
|
||||
|
||||
interface SpotlightAlert {
|
||||
tone: SpotlightAlertTone;
|
||||
title: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface MessagingAlertContent {
|
||||
tone: SpotlightAlertTone;
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const Messages = () => {
|
||||
const haptics = useHaptics();
|
||||
@@ -233,6 +246,7 @@ export const Messages = () => {
|
||||
const [aiShareLoadingSessions, setAiShareLoadingSessions] = createSignal(false);
|
||||
const [aiShareLoadingMessages, setAiShareLoadingMessages] = createSignal(false);
|
||||
const [composerAiReferences, setComposerAiReferences] = createSignal<ComposerAIReference[]>([]);
|
||||
const [spotlightAlert, setSpotlightAlert] = createSignal<SpotlightAlert | null>(null);
|
||||
|
||||
const getCurrentUserId = () => {
|
||||
const raw = localStorage.getItem('trackeep_user') || localStorage.getItem('user');
|
||||
@@ -271,6 +285,7 @@ export const Messages = () => {
|
||||
let composerTextareaRef: HTMLTextAreaElement | null = null;
|
||||
let hiddenFileInputRef: HTMLInputElement | null = null;
|
||||
const sensitiveRevealTimers = new Map<number, number>();
|
||||
let spotlightAlertTimer: number | null = null;
|
||||
|
||||
const sortedConversations = () =>
|
||||
[...conversations()].sort((a, b) => {
|
||||
@@ -299,6 +314,25 @@ export const Messages = () => {
|
||||
setActiveScreen('list');
|
||||
};
|
||||
|
||||
const showSpotlightAlert = (tone: SpotlightAlertTone, title: string, message?: string) => {
|
||||
setSpotlightAlert({ tone, title, message });
|
||||
if (spotlightAlertTimer) {
|
||||
window.clearTimeout(spotlightAlertTimer);
|
||||
}
|
||||
spotlightAlertTimer = window.setTimeout(() => {
|
||||
setSpotlightAlert(null);
|
||||
spotlightAlertTimer = null;
|
||||
}, 7000);
|
||||
};
|
||||
|
||||
const dismissSpotlightAlert = () => {
|
||||
if (spotlightAlertTimer) {
|
||||
window.clearTimeout(spotlightAlertTimer);
|
||||
spotlightAlertTimer = null;
|
||||
}
|
||||
setSpotlightAlert(null);
|
||||
};
|
||||
|
||||
const shouldUseSensitiveReveal = () => {
|
||||
const type = activeConversation()?.conversation.type;
|
||||
return type === 'dm' || type === 'self';
|
||||
@@ -307,6 +341,104 @@ export const Messages = () => {
|
||||
const conversationNameById = (id: number) =>
|
||||
conversations().find((item) => item.conversation.id === id)?.conversation.name || 'Conversation';
|
||||
|
||||
const formatConversationPreview = (item: ConversationListItem) => {
|
||||
const body = item.last_message?.body?.trim();
|
||||
if (body) return body;
|
||||
if (item.conversation.topic?.trim()) return item.conversation.topic.trim();
|
||||
|
||||
switch (item.conversation.type) {
|
||||
case 'dm':
|
||||
return 'Direct message';
|
||||
case 'group':
|
||||
return 'Group chat';
|
||||
case 'team':
|
||||
return 'Team channel';
|
||||
case 'global':
|
||||
return 'Workspace channel';
|
||||
case 'self':
|
||||
return 'Notes to self';
|
||||
case 'password_vault':
|
||||
return 'Password vault';
|
||||
default:
|
||||
return 'Conversation';
|
||||
}
|
||||
};
|
||||
|
||||
const formatConversationTime = (item: ConversationListItem) => {
|
||||
const raw = item.conversation.last_message_at || item.last_message?.created_at || item.conversation.updated_at;
|
||||
const parsed = new Date(raw);
|
||||
if (Number.isNaN(parsed.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const sameDay = parsed.toDateString() === now.toDateString();
|
||||
if (sameDay) {
|
||||
return parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (parsed.toDateString() === yesterday.toDateString()) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
|
||||
return parsed.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
const getConversationInitials = (item: ConversationListItem) => {
|
||||
const label = (item.conversation.name || item.conversation.type || 'C').trim();
|
||||
if (label.startsWith('#')) {
|
||||
return '#';
|
||||
}
|
||||
|
||||
const parts = label.split(/\s+/).filter(Boolean).slice(0, 2);
|
||||
if (parts.length === 0) return 'C';
|
||||
return parts.map((part) => part.charAt(0).toUpperCase()).join('');
|
||||
};
|
||||
|
||||
const getConversationAvatarClass = (item: ConversationListItem) => {
|
||||
switch (item.conversation.type) {
|
||||
case 'dm':
|
||||
return 'conversation-item-avatar-dm';
|
||||
case 'group':
|
||||
return 'conversation-item-avatar-group';
|
||||
case 'team':
|
||||
return 'conversation-item-avatar-team';
|
||||
case 'global':
|
||||
return 'conversation-item-avatar-global';
|
||||
case 'self':
|
||||
return 'conversation-item-avatar-self';
|
||||
case 'password_vault':
|
||||
return 'conversation-item-avatar-vault';
|
||||
default:
|
||||
return 'conversation-item-avatar-default';
|
||||
}
|
||||
};
|
||||
|
||||
const formatMessagingAlert = (error: unknown, fallbackTitle: string): MessagingAlertContent => {
|
||||
const rawMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (/Failed to initialize messaging defaults/i.test(rawMessage)) {
|
||||
return {
|
||||
tone: 'error',
|
||||
title: 'Messaging is not ready yet',
|
||||
message: 'The backend could not provision messaging defaults. Reload after the migration fix is applied.',
|
||||
};
|
||||
}
|
||||
|
||||
if (/User not authenticated/i.test(rawMessage)) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
title: 'Session expired',
|
||||
message: 'Sign in again to keep using messages.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'error',
|
||||
title: fallbackTitle,
|
||||
message: rawMessage,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeReactionKey = (value: string): ReactionKey => {
|
||||
const raw = value.trim().toLowerCase();
|
||||
if (!raw) return 'thumb_up';
|
||||
@@ -568,6 +700,7 @@ export const Messages = () => {
|
||||
const requestNotificationPermission = async () => {
|
||||
if (!('Notification' in window)) {
|
||||
toast.warning('Browser notifications unavailable', 'This browser does not support notifications.');
|
||||
showSpotlightAlert('warning', 'Browser notifications unavailable', 'This browser does not support notifications.');
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === 'granted') {
|
||||
@@ -579,6 +712,15 @@ export const Messages = () => {
|
||||
const enabled = permission === 'granted';
|
||||
setBrowserNotificationsEnabled(enabled);
|
||||
localStorage.setItem('messages_browser_notifications', enabled ? 'true' : 'false');
|
||||
if (!enabled) {
|
||||
showSpotlightAlert(
|
||||
'warning',
|
||||
permission === 'denied' ? 'Notifications blocked' : 'Permission dismissed',
|
||||
permission === 'denied'
|
||||
? 'Browser notifications are blocked. Enable them in your browser settings if you want message alerts.'
|
||||
: 'Notifications stay off until you allow them.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const maybeNotify = (title: string, body: string) => {
|
||||
@@ -1049,6 +1191,7 @@ export const Messages = () => {
|
||||
toast.info('Voice call', 'Call connected.');
|
||||
} catch (error) {
|
||||
setCallStatus('error');
|
||||
showSpotlightAlert('error', 'Failed to answer call', error instanceof Error ? error.message : 'Unknown error');
|
||||
toast.error('Failed to answer call', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
@@ -1125,6 +1268,7 @@ export const Messages = () => {
|
||||
.map((member) => member.user_id)
|
||||
.filter((id) => id !== currentUserId());
|
||||
if (peers.length === 0) {
|
||||
showSpotlightAlert('warning', 'No participants', 'Add at least one other user to call.');
|
||||
toast.warning('No participants', 'Add at least one other user to call.');
|
||||
return;
|
||||
}
|
||||
@@ -1153,6 +1297,7 @@ export const Messages = () => {
|
||||
} catch (error) {
|
||||
setCallStatus('error');
|
||||
cleanupCallResources();
|
||||
showSpotlightAlert('error', 'Failed to start call', error instanceof Error ? error.message : 'Unknown error');
|
||||
toast.error('Failed to start call', error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
@@ -1181,9 +1326,16 @@ export const Messages = () => {
|
||||
if (nextConversations.length === 0) {
|
||||
setSelectedConversationId(null);
|
||||
setActiveScreen('list');
|
||||
} else if (!selectedID) {
|
||||
const firstConversationID = nextConversations[0].conversation.id;
|
||||
setSelectedConversationId(firstConversationID);
|
||||
if (window.innerWidth >= 768) {
|
||||
setActiveScreen('conversation');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load conversations', error instanceof Error ? error.message : 'Unknown error');
|
||||
const alert = formatMessagingAlert(error, 'Failed to load conversations');
|
||||
showSpotlightAlert(alert.tone, alert.title, alert.message);
|
||||
} finally {
|
||||
setLoadingConversations(false);
|
||||
}
|
||||
@@ -1195,7 +1347,8 @@ export const Messages = () => {
|
||||
const data = await messagesApi.getMessages(conversationId, undefined, 50);
|
||||
setMessages(data.messages || []);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load messages', error instanceof Error ? error.message : 'Unknown error');
|
||||
const alert = formatMessagingAlert(error, 'Failed to load messages');
|
||||
showSpotlightAlert(alert.tone, alert.title, alert.message);
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
@@ -1906,6 +2059,10 @@ export const Messages = () => {
|
||||
window.clearTimeout(typingStopTimer);
|
||||
typingStopTimer = null;
|
||||
}
|
||||
if (spotlightAlertTimer) {
|
||||
window.clearTimeout(spotlightAlertTimer);
|
||||
spotlightAlertTimer = null;
|
||||
}
|
||||
for (const timer of Array.from(sensitiveRevealTimers.values())) {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
@@ -1948,9 +2105,73 @@ export const Messages = () => {
|
||||
onCleanup(() => clearInterval(wsWatcher));
|
||||
|
||||
return (
|
||||
<div class="mt-4 pb-32 max-w-7xl mx-auto">
|
||||
<div class="bg-background rounded-lg border shadow-sm">
|
||||
<div class={`messages-shell ${activeScreen() === 'conversation' ? 'messages-shell-conversation' : 'messages-shell-list'}`}>
|
||||
<div class="h-full w-full flex flex-col bg-background">
|
||||
<header class="border-b bg-card/95 backdrop-blur-sm z-10">
|
||||
<div class="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setActiveScreen((screen) => (screen === 'conversation' ? 'list' : selectedConversationId() ? 'conversation' : 'list'))}
|
||||
class="md:hidden"
|
||||
>
|
||||
<IconMessageCircle class="size-4" />
|
||||
</Button>
|
||||
<div class="w-8 h-8 bg-muted rounded-lg flex items-center justify-center shrink-0">
|
||||
<IconMessageCircle class="size-5 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="font-semibold text-lg text-foreground">Messages</h1>
|
||||
<p class="text-sm text-muted-foreground truncate">Direct messages, channels, calls, and AI references</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setSearchOpen(true)}>
|
||||
<IconSearch class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (browserNotificationsEnabled()) {
|
||||
setBrowserNotificationsEnabled(false);
|
||||
localStorage.setItem('messages_browser_notifications', 'false');
|
||||
} else {
|
||||
requestNotificationPermission();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Show when={browserNotificationsEnabled()} fallback={<IconBellOff class="size-4" />}>
|
||||
<IconBell class="size-4" />
|
||||
</Show>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowCreateConversation(true)} class="hidden sm:inline-flex">
|
||||
<IconPlus class="size-4 mr-1" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Show when={spotlightAlert()}>
|
||||
{(alert) => (
|
||||
<div class="messages-alert-wrap">
|
||||
<div class={`messages-spotlight-alert messages-spotlight-${alert().tone}`}>
|
||||
<div class="messages-spotlight-copy">
|
||||
<p class="font-medium text-foreground">{alert().title}</p>
|
||||
<Show when={alert().message}>
|
||||
<p class="text-sm text-muted-foreground mt-1">{alert().message}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={dismissSpotlightAlert}>
|
||||
<IconX class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<div class={`messages-shell ${activeScreen() === 'conversation' ? 'messages-shell-conversation' : 'messages-shell-list'}`}>
|
||||
<aside class="messages-sidebar">
|
||||
<div class="messages-sidebar-header">
|
||||
<div class="messages-title-row">
|
||||
@@ -2009,11 +2230,22 @@ export const Messages = () => {
|
||||
}`}
|
||||
onClick={() => openConversation(item.conversation.id)}
|
||||
>
|
||||
<div class={`conversation-item-avatar ${getConversationAvatarClass(item)}`}>
|
||||
<span>{getConversationInitials(item)}</span>
|
||||
</div>
|
||||
<div class="conversation-item-main">
|
||||
<p class="conversation-item-name">{item.conversation.name}</p>
|
||||
<p class="conversation-item-preview">
|
||||
{item.last_message?.body || item.conversation.topic || item.conversation.type}
|
||||
</p>
|
||||
<div class="conversation-item-heading">
|
||||
<p class="conversation-item-name">{item.conversation.name}</p>
|
||||
<Show when={formatConversationTime(item)}>
|
||||
<span class="conversation-item-time">{formatConversationTime(item)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="conversation-item-footer">
|
||||
<p class="conversation-item-preview">{formatConversationPreview(item)}</p>
|
||||
<Show when={item.conversation.is_default}>
|
||||
<span class="conversation-item-badge">Default</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={item.unread_count > 0}>
|
||||
<span class="conversation-item-unread">{item.unread_count}</span>
|
||||
@@ -2075,7 +2307,19 @@ export const Messages = () => {
|
||||
when={selectedConversationId()}
|
||||
fallback={
|
||||
<div class="messages-main-empty">
|
||||
Select a conversation from the list.
|
||||
<div class="messages-main-empty-card">
|
||||
<div class="messages-main-empty-orb">
|
||||
<IconMessageCircle class="size-6 text-primary" />
|
||||
</div>
|
||||
<h3 class="messages-main-empty-title">Pick a conversation</h3>
|
||||
<p class="messages-main-empty-copy">
|
||||
Jump into your self chat, browse workspace channels, or start a new direct message.
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateConversation(true)}>
|
||||
<IconPlus class="size-4 mr-1" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -2574,6 +2818,7 @@ export const Messages = () => {
|
||||
</div>
|
||||
</Show>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Show when={showCreateConversation()}>
|
||||
<div
|
||||
@@ -2989,8 +3234,6 @@ export const Messages = () => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+324
-393
@@ -1,8 +1,10 @@
|
||||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
|
||||
import { createSignal, onMount, For, Show } from 'solid-js';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SearchTagFilterBar } from '@/components/ui/SearchTagFilterBar';
|
||||
import { NoteModal } from '@/components/ui/NoteModal';
|
||||
import { ViewNoteModal } from '@/components/ui/ViewNoteModal';
|
||||
import { NoteContentRenderer } from '@/components/notes/NoteContentRenderer';
|
||||
import { IconPin, IconTrash, IconEdit, IconCopy, IconDownload, IconPaperclip } from '@tabler/icons-solidjs';
|
||||
import { getMockNotes } from '@/lib/mockData';
|
||||
import { isDemoMode, shouldUseRealBackend } from '@/lib/demo-mode';
|
||||
@@ -30,46 +32,69 @@ interface Note {
|
||||
isHtml?: boolean;
|
||||
}
|
||||
|
||||
const renderMarkdownPreviewHtml = (content: string, maxBlocks = 4): string => {
|
||||
const html = content
|
||||
.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-1">$1<\/h1>')
|
||||
.replace(/^## (.*$)/gim, '<h2 class="text-sm font-semibold mb-1">$1<\/h2>')
|
||||
.replace(/^### (.*$)/gim, '<h3 class="text-sm font-semibold mb-1">$1<\/h3>')
|
||||
.replace(/^#### (.*$)/gim, '<h4 class="text-xs font-semibold mb-1">$1<\/h4>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1<\/strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#262626] px-1 py-0.5 rounded text-xs">$1<\/code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#262626] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2<\/code><\/pre>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="this.parentElement.nextElementSibling.textContent=this.checked?\'x\':\' \'"><span class="text-xs">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked" onchange="this.parentElement.nextElementSibling.textContent=this.checked?\'x\':\' \'"><span class="text-xs">$1</span></div>')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1<\/li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1<\/li>')
|
||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#444] pl-3 italic text-[#aaa] mb-2">$1<\/blockquote>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1<\/a>')
|
||||
.replace(/\n\n+/g, '<\/p><p class="mb-2">');
|
||||
|
||||
const parts = html.split('<\/p><p class="mb-2">');
|
||||
const limited = parts.slice(0, maxBlocks).join('<\/p><p class="mb-2">');
|
||||
return limited;
|
||||
const normalizeNoteId = (value: unknown, fallback: number): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const digits = value.match(/\d+/)?.[0];
|
||||
if (digits) {
|
||||
const parsed = Number(digits);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const renderPlainTextPreviewHtml = (content: string): string => {
|
||||
return content
|
||||
.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">$1<\/a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1<\/strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1<\/em>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked"><span class="text-xs">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #3b82f6;" onclick="this.checked=!this.checked"><span class="text-xs">$1</span></div>')
|
||||
.split('\n')
|
||||
.slice(0, 6)
|
||||
.map((line) => (line ? line : '<br \/>'))
|
||||
.join('\n');
|
||||
const normalizeNoteDate = (value: unknown, fallback: Date = new Date()): string => {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = new Date(value);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
const relativeMatch = value.trim().match(/^(\d+)\s+(minute|hour|day|week|month|year)s?\s+ago$/i);
|
||||
if (relativeMatch) {
|
||||
const amount = Number(relativeMatch[1]);
|
||||
const unit = relativeMatch[2].toLowerCase();
|
||||
const relativeDate = new Date();
|
||||
const operations: Record<string, (date: Date, quantity: number) => void> = {
|
||||
minute: (date, quantity) => date.setMinutes(date.getMinutes() - quantity),
|
||||
hour: (date, quantity) => date.setHours(date.getHours() - quantity),
|
||||
day: (date, quantity) => date.setDate(date.getDate() - quantity),
|
||||
week: (date, quantity) => date.setDate(date.getDate() - quantity * 7),
|
||||
month: (date, quantity) => date.setMonth(date.getMonth() - quantity),
|
||||
year: (date, quantity) => date.setFullYear(date.getFullYear() - quantity),
|
||||
};
|
||||
operations[unit]?.(relativeDate, amount);
|
||||
return relativeDate.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return fallback.toISOString();
|
||||
};
|
||||
|
||||
const getNoteKind = (note: Note): 'html' | 'markdown' | 'plain' => {
|
||||
if (note.isHtml) return 'html';
|
||||
if (note.isMarkdown) return 'markdown';
|
||||
return 'plain';
|
||||
};
|
||||
|
||||
const formatDisplayDate = (value: string): string => {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return 'Unknown';
|
||||
}
|
||||
return parsed.toLocaleDateString();
|
||||
};
|
||||
|
||||
export const Notes = () => {
|
||||
const haptics = useHaptics();
|
||||
const [notes, setNotes] = createSignal<Note[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(true);
|
||||
const [loadError, setLoadError] = createSignal('');
|
||||
const [searchTerm, setSearchTerm] = createSignal('');
|
||||
const [selectedTags, setSelectedTags] = createSignal<string[]>([]);
|
||||
const [showAddModal, setShowAddModal] = createSignal(false);
|
||||
@@ -83,6 +108,7 @@ export const Notes = () => {
|
||||
onMount(async () => {
|
||||
try {
|
||||
let notesData: any[] = [];
|
||||
setLoadError('');
|
||||
|
||||
// Check if we should use demo mode or real API
|
||||
if (isDemoMode() && !shouldUseRealBackend()) {
|
||||
@@ -113,11 +139,11 @@ export const Notes = () => {
|
||||
: [];
|
||||
|
||||
const content = note.content || '';
|
||||
const createdAt = note.created_at || note.createdAt || new Date().toISOString();
|
||||
const updatedAt = note.updated_at || note.updatedAt || createdAt;
|
||||
const createdAt = normalizeNoteDate(note.created_at || note.createdAt || note.createdAtLabel, new Date());
|
||||
const updatedAt = normalizeNoteDate(note.updated_at || note.updatedAt || createdAt, new Date(createdAt));
|
||||
|
||||
return {
|
||||
id: Number(note.id || index + 1),
|
||||
id: normalizeNoteId(note.id, index + 1),
|
||||
title: note.title || 'Untitled note',
|
||||
content,
|
||||
createdAt,
|
||||
@@ -133,7 +159,7 @@ export const Notes = () => {
|
||||
url: att.url,
|
||||
}))
|
||||
: [],
|
||||
isMarkdown: content.includes('#') || content.includes('*'),
|
||||
isMarkdown: Boolean(note.isMarkdown) || content.includes('#') || content.includes('*') || content.includes('```'),
|
||||
isHtml: content.includes('<') && content.includes('>'),
|
||||
};
|
||||
});
|
||||
@@ -141,44 +167,9 @@ export const Notes = () => {
|
||||
setNotes(adaptedNotes);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notes:', error);
|
||||
// Fallback to demo data if API fails
|
||||
if (!isDemoMode()) {
|
||||
console.log('[Notes] API failed, falling back to demo data');
|
||||
const mockNotesData = getMockNotes();
|
||||
const adaptedNotes: Note[] = mockNotesData.map((note: any, index) => {
|
||||
const tags = Array.isArray(note.tags)
|
||||
? note.tags.map((tag: any) => (typeof tag === 'string' ? tag : tag?.name)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const content = note.content || '';
|
||||
const createdAt = note.created_at || note.createdAt || new Date().toISOString();
|
||||
const updatedAt = note.updated_at || note.updatedAt || createdAt;
|
||||
|
||||
return {
|
||||
id: Number(note.id || index + 1),
|
||||
title: note.title || 'Untitled note',
|
||||
content,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
tags,
|
||||
pinned: Boolean(note.pinned ?? note.is_pinned ?? tags.includes('pinned')),
|
||||
attachments: Array.isArray(note.attachments)
|
||||
? note.attachments.map((att: any, attachmentIndex: number) => ({
|
||||
id: String(att.id || `att_${attachmentIndex}`),
|
||||
name: att.name || 'attachment',
|
||||
type: att.type || 'file',
|
||||
size: att.size || '',
|
||||
url: att.url,
|
||||
}))
|
||||
: [],
|
||||
isMarkdown: content.includes('#') || content.includes('*'),
|
||||
isHtml: content.includes('<') && content.includes('>'),
|
||||
};
|
||||
});
|
||||
setNotes(adaptedNotes);
|
||||
} else {
|
||||
setNotes([]);
|
||||
}
|
||||
setNotes([]);
|
||||
const message = error instanceof Error ? error.message : 'Failed to load notes.';
|
||||
setLoadError(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -225,14 +216,17 @@ export const Notes = () => {
|
||||
try {
|
||||
if (isDemoMode() && !shouldUseRealBackend()) {
|
||||
// Demo mode: Add note locally
|
||||
const now = new Date();
|
||||
const note: Note = {
|
||||
id: Date.now(),
|
||||
title: noteData.title,
|
||||
content: noteData.content,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
tags: noteData.tags,
|
||||
pinned: false
|
||||
pinned: false,
|
||||
isMarkdown: Boolean(noteData.content?.includes('```') || noteData.content?.includes('#')),
|
||||
isHtml: Boolean(noteData.content?.includes('<') && noteData.content?.includes('>')),
|
||||
};
|
||||
|
||||
setNotes(prev => [note, ...prev]);
|
||||
@@ -255,16 +249,16 @@ export const Notes = () => {
|
||||
|
||||
const newNote = await response.json();
|
||||
const adaptedNote: Note = {
|
||||
id: newNote.id,
|
||||
id: normalizeNoteId(newNote.id, Date.now()),
|
||||
title: newNote.title,
|
||||
content: newNote.content,
|
||||
createdAt: newNote.created_at || newNote.createdAt,
|
||||
updatedAt: newNote.updated_at || newNote.updatedAt,
|
||||
content: newNote.content || '',
|
||||
createdAt: normalizeNoteDate(newNote.created_at || newNote.createdAt || new Date().toISOString()),
|
||||
updatedAt: normalizeNoteDate(newNote.updated_at || newNote.updatedAt || newNote.created_at || new Date().toISOString()),
|
||||
tags: Array.isArray(newNote.tags) ? newNote.tags.map((tag: any) => typeof tag === 'string' ? tag : tag.name) : [],
|
||||
pinned: Boolean(newNote.pinned ?? newNote.is_pinned),
|
||||
attachments: [],
|
||||
isMarkdown: newNote.content.includes('#') || newNote.content.includes('*'),
|
||||
isHtml: newNote.content.includes('<') && newNote.content.includes('>'),
|
||||
isMarkdown: Boolean(newNote.content?.includes('#') || newNote.content?.includes('*') || newNote.content?.includes('```')),
|
||||
isHtml: Boolean(newNote.content?.includes('<') && newNote.content?.includes('>')),
|
||||
};
|
||||
|
||||
setNotes(prev => [adaptedNote, ...prev]);
|
||||
@@ -431,7 +425,6 @@ export const Notes = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Add this function to handle checkbox changes
|
||||
const updateNoteCheckbox = (noteId: number, checkboxIndex: number, isChecked: boolean) => {
|
||||
setNotes(prev => prev.map(note => {
|
||||
if (note.id === noteId) {
|
||||
@@ -443,11 +436,12 @@ export const Notes = () => {
|
||||
const checkedMatch = line.match(/^- \[x\] (.*)$/);
|
||||
|
||||
if (uncheckedMatch || checkedMatch) {
|
||||
if (checkboxCount === checkboxIndex) {
|
||||
const currentCheckboxIndex = checkboxCount;
|
||||
checkboxCount++;
|
||||
if (currentCheckboxIndex === checkboxIndex) {
|
||||
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
|
||||
return isChecked ? `- [x] ${text}` : `- [ ] ${text}`;
|
||||
}
|
||||
checkboxCount++;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
@@ -460,6 +454,35 @@ export const Notes = () => {
|
||||
}
|
||||
return note;
|
||||
}));
|
||||
|
||||
setViewingNote(prev => {
|
||||
if (!prev || prev.id !== noteId) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const lines = prev.content.split('\n');
|
||||
let checkboxCount = 0;
|
||||
const updatedLines = lines.map(line => {
|
||||
const uncheckedMatch = line.match(/^- \[ \] (.*)$/);
|
||||
const checkedMatch = line.match(/^- \[x\] (.*)$/);
|
||||
|
||||
if (uncheckedMatch || checkedMatch) {
|
||||
const currentCheckboxIndex = checkboxCount;
|
||||
checkboxCount++;
|
||||
if (currentCheckboxIndex === checkboxIndex) {
|
||||
const text = uncheckedMatch ? uncheckedMatch[1] : (checkedMatch ? checkedMatch[1] : '');
|
||||
return isChecked ? `- [x] ${text}` : `- [ ] ${text}`;
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
return {
|
||||
...prev,
|
||||
content: updatedLines.join('\n'),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Handler for updating note content from ViewNoteModal
|
||||
@@ -469,327 +492,235 @@ export const Notes = () => {
|
||||
? { ...note, content, updatedAt: new Date().toISOString() }
|
||||
: note
|
||||
));
|
||||
setViewingNote(prev =>
|
||||
prev && prev.id === noteId
|
||||
? { ...prev, content, updatedAt: new Date().toISOString() }
|
||||
: prev
|
||||
);
|
||||
};
|
||||
|
||||
// Make the function available globally for checkbox onchange handlers
|
||||
createEffect(() => {
|
||||
(window as any).updateNoteContent = (checkbox: HTMLInputElement) => {
|
||||
const noteElement = checkbox.closest('[data-note-id]');
|
||||
if (noteElement) {
|
||||
const noteId = parseInt(noteElement.getAttribute('data-note-id') || '0');
|
||||
const checkboxElements = noteElement.querySelectorAll('input[type="checkbox"]');
|
||||
const checkboxIndex = Array.from(checkboxElements).indexOf(checkbox);
|
||||
updateNoteCheckbox(noteId, checkboxIndex, checkbox.checked);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="fixed inset-0 bg-background flex flex-col">
|
||||
<style>
|
||||
{`
|
||||
.note-checkbox {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
cursor: pointer !important;
|
||||
accent-color: #3b82f6 !important;
|
||||
border: 2px solid #4b5563 !important;
|
||||
border-radius: 3px !important;
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
.note-checkbox:hover {
|
||||
border-color: #3b82f6 !important;
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
.note-checkbox:checked {
|
||||
background-color: #3b82f6 !important;
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
.note-checkbox:focus {
|
||||
outline: 2px solid #3b82f6 !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
{/* Header - Signal/Discord style */}
|
||||
<div class="bg-[#2c2c2e] border-b border-[#404040] px-4 py-3 flex-shrink-0">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-white">Notes</h1>
|
||||
<p class="text-sm text-[#b9b9b9]">Your personal notes</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowAddModal(true);
|
||||
haptics.impact();
|
||||
}}
|
||||
class="bg-[#5865f2] hover:bg-[#4752c4] text-white border-0"
|
||||
>
|
||||
Add Note
|
||||
</Button>
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-foreground">Notes</h1>
|
||||
<p class="text-muted-foreground mt-1">Your personal notes</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowAddModal(true);
|
||||
haptics.impact();
|
||||
}}
|
||||
>
|
||||
Add Note
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
{/* Sidebar - Discord style */}
|
||||
<div class="w-64 bg-[#202225] border-r border-[#404040] flex-shrink-0 overflow-y-auto">
|
||||
<div class="p-4">
|
||||
<h3 class="text-xs font-semibold text-[#8e9297] uppercase tracking-wide mb-3">Search & Filter</h3>
|
||||
<SearchTagFilterBar
|
||||
searchPlaceholder="Search notes..."
|
||||
searchValue={searchTerm()}
|
||||
onSearchChange={(value) => setSearchTerm(value)}
|
||||
tagOptions={allTags()}
|
||||
selectedTag={selectedTags()[0] || ''}
|
||||
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
|
||||
onReset={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedTags([]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mt-6">
|
||||
<h3 class="text-xs font-semibold text-[#8e9297] uppercase tracking-wide mb-3">Quick Stats</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#b9b9b9]">Total Notes</span>
|
||||
<span class="text-sm font-medium text-white">{filteredNotes().length}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#b9b9b9]">Pinned</span>
|
||||
<span class="text-sm font-medium text-white">{filteredNotes().filter(n => n.pinned).length}</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-[#b9b9b9]">Tags</span>
|
||||
<span class="text-sm font-medium text-white">{allTags().length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SearchTagFilterBar
|
||||
searchPlaceholder="Search notes..."
|
||||
searchValue={searchTerm()}
|
||||
onSearchChange={(value) => setSearchTerm(value)}
|
||||
tagOptions={allTags()}
|
||||
selectedTag={selectedTags()[0] || ''}
|
||||
onTagChange={(value) => setSelectedTags(value ? [value] : [])}
|
||||
onReset={() => {
|
||||
setSearchTerm('');
|
||||
setSelectedTags([]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Card class="p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Total Notes</p>
|
||||
<p class="text-xl font-semibold text-foreground">{filteredNotes().length}</p>
|
||||
</Card>
|
||||
<Card class="p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Pinned</p>
|
||||
<p class="text-xl font-semibold text-foreground">{filteredNotes().filter((note) => note.pinned).length}</p>
|
||||
</Card>
|
||||
<Card class="p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-muted-foreground mb-1">Tags</p>
|
||||
<p class="text-xl font-semibold text-foreground">{allTags().length}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Show when={loadError()}>
|
||||
<Card class="border-destructive/30 bg-destructive/5 p-4">
|
||||
<p class="text-sm font-medium text-foreground">Notes could not be loaded</p>
|
||||
<p class="mt-1 text-sm text-muted-foreground">{loadError()}</p>
|
||||
</Card>
|
||||
</Show>
|
||||
|
||||
<Show when={copiedContent()}>
|
||||
<div class="bg-primary/15 text-primary px-3 py-1 rounded-md text-sm">
|
||||
Content copied!
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Notes List - Signal style chat */}
|
||||
<div class="flex-1 bg-[#36393f] overflow-y-auto">
|
||||
<div class="p-4 space-y-2">
|
||||
<Show when={copiedContent()}>
|
||||
<div class="bg-green-500/20 border border-green-500/50 text-green-400 px-3 py-2 rounded-lg text-sm mb-4">
|
||||
Content copied!
|
||||
<Show when={isLoading()}>
|
||||
<div class="space-y-4">
|
||||
{[...Array(3)].map(() => (
|
||||
<Card class="p-6">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-6 bg-muted rounded mb-2"></div>
|
||||
<div class="h-4 bg-muted rounded w-3/4"></div>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{isLoading() ? (
|
||||
<div class="space-y-3">
|
||||
{[...Array(3)].map(() => (
|
||||
<div class="bg-[#2c2c2e] rounded-lg p-4 animate-pulse">
|
||||
<div class="h-4 bg-[#404040] rounded mb-2"></div>
|
||||
<div class="h-3 bg-[#404040] rounded w-3/4"></div>
|
||||
<Show when={!isLoading()}>
|
||||
<div class="space-y-4">
|
||||
<For each={filteredNotes()}>
|
||||
{(note) => (
|
||||
<Card
|
||||
data-note-id={note.id}
|
||||
class={`p-6 cursor-pointer transition-colors hover:bg-accent/50 ${note.pinned ? 'border-l-4 border-l-primary' : ''}`}
|
||||
onClick={() => viewNote(note)}
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3 gap-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h3 class="text-lg font-semibold text-foreground truncate">{note.title}</h3>
|
||||
<Show when={note.pinned}>
|
||||
<IconPin class="size-4 text-primary" />
|
||||
</Show>
|
||||
<Show when={note.isMarkdown}>
|
||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">MD</span>
|
||||
</Show>
|
||||
<Show when={note.isHtml}>
|
||||
<span class="text-xs px-2 py-1 bg-primary/10 text-primary rounded">HTML</span>
|
||||
</Show>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredNotes().length > 0 ? (
|
||||
filteredNotes().map((note) => (
|
||||
<div
|
||||
data-note-id={note.id}
|
||||
class={`bg-[#2c2c2e] hover:bg-[#35363c] rounded-lg p-4 cursor-pointer transition-all border border-transparent ${note.pinned ? 'border-l-4 border-l-[#5865f2]' : ''}`}
|
||||
onClick={() => viewNote(note)}
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyNoteContent(note);
|
||||
}}
|
||||
class="text-muted-foreground hover:text-foreground p-1"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-white font-medium">{note.title}</h3>
|
||||
{note.pinned && <IconPin class="size-4 text-[#faa61a]" />}
|
||||
{note.isMarkdown && <span class="text-xs px-2 py-1 bg-[#5865f2]/20 text-[#5865f2] rounded">MD</span>}
|
||||
{note.isHtml && <span class="text-xs px-2 py-1 bg-[#5865f2]/20 text-[#5865f2] rounded">HTML</span>}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyNoteContent(note);
|
||||
}}
|
||||
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
|
||||
>
|
||||
<IconCopy size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
exportNote(note);
|
||||
}}
|
||||
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
|
||||
>
|
||||
<IconDownload size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditNote(note);
|
||||
}}
|
||||
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePin(note.id);
|
||||
}}
|
||||
class="text-[#b9b9b9] hover:text-white p-1 h-6 w-6"
|
||||
{...{title: note.pinned ? "Unpin note" : "Pin note"}}
|
||||
>
|
||||
<IconPin size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNote(note.id);
|
||||
}}
|
||||
class="text-[#ed4245] hover:text-[#ff6b6b] p-1 h-6 w-6"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note Preview */}
|
||||
<div class="text-[#dcddde] text-sm">
|
||||
<div class="prose prose-invert max-w-none">
|
||||
<Show
|
||||
when={expandedNotes().has(note.id)}
|
||||
fallback={
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
'-webkit-line-clamp': '2',
|
||||
'-webkit-box-orient': 'vertical',
|
||||
'max-height': '3em',
|
||||
'line-height': '1.5em'
|
||||
}}
|
||||
innerHTML={
|
||||
note.isHtml
|
||||
? note.content
|
||||
: note.isMarkdown
|
||||
? renderMarkdownPreviewHtml(note.content)
|
||||
: renderPlainTextPreviewHtml(note.content)
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div
|
||||
innerHTML={
|
||||
note.isHtml
|
||||
? note.content
|
||||
: note.isMarkdown
|
||||
? note.content.replace(/^# (.*$)/gim, '<h1 class="text-base font-semibold mb-2">$1</h1>')
|
||||
.replace(/^## (.*$)/gim, '<h2 class="text-sm font-semibold mb-1">$1</h2>')
|
||||
.replace(/^### (.*$)/gim, '<h3 class="text-sm font-semibold mb-1">$1</h3>')
|
||||
.replace(/^#### (.*$)/gim, '<h4 class="text-xs font-semibold mb-1">$1</h4>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code class="bg-[#404040] px-1 py-0.5 rounded text-xs">$1</code>')
|
||||
.replace(/```(.*?)\n([\s\S]*?)```/g, '<pre class="bg-[#404040] p-3 rounded mb-2 overflow-x-auto"><code class="text-xs">$2</code></pre>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- (.*$)/gim, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^\d+\. (.*$)/gim, '<li class="ml-4 list-decimal">$1</li>')
|
||||
.replace(/> (.*$)/gim, '<blockquote class="border-l-4 border-[#404040] pl-3 italic text-[#b9b9b9] mb-2">$1</blockquote>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-[#5865f2] hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\n\n+/g, '</p><p class="mb-2">')
|
||||
: note.content.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" class="text-[#5865f2] hover:underline" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold">$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em class="italic">$1</em>')
|
||||
.replace(/^- \[ \] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.replace(/^- \[x\] (.*$)/gim, '<div class="flex items-center gap-2 mb-1"><input type="checkbox" checked class="note-checkbox" style="width: 16px; height: 16px; cursor: pointer; accent-color: #5865f2;" onclick="this.checked=!this.checked" onchange="updateNoteContent(this)"><span class="text-sm">$1</span></div>')
|
||||
.split('\n').map((line) => line ? `<p class="mb-2">${line}</p>` : '<br />').join('')
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
console.log('Show more clicked for note:', note.title);
|
||||
toggleNoteExpansion(note.id);
|
||||
}}
|
||||
class="mt-2 text-xs text-[#5865f2] hover:text-[#7289da] font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div class="flex flex-wrap gap-1 mt-3">
|
||||
<For each={note.tags}>
|
||||
{(tag) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTag(tag);
|
||||
}}
|
||||
class="px-2 py-1 bg-[#404040] hover:bg-[#4a4b4e] text-[#b9b9b9] hover:text-white text-xs rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<Show when={note.attachments && note.attachments.length > 0}>
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<IconPaperclip class="size-3 text-[#b9b9b9]" />
|
||||
<span class="text-xs text-[#b9b9b9]">Attachments ({note.attachments?.length || 0})</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={note.attachments || []}>
|
||||
{(attachment) => (
|
||||
<div class="flex items-center gap-2 px-2 py-1 bg-[#404040] rounded-md text-xs">
|
||||
<span class="text-[#dcddde]">{attachment.name}</span>
|
||||
<span class="text-[#8e9297]">({attachment.size})</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-between items-center mt-3 pt-3 border-t border-[#404040]">
|
||||
<p class="text-xs text-[#8e9297]">
|
||||
Updated: {note.updatedAt && !isNaN(new Date(note.updatedAt).getTime()) ? new Date(note.updatedAt).toLocaleDateString() : 'Invalid Date'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div class="bg-[#2c2c2e] rounded-lg p-8 text-center">
|
||||
<div class="text-6xl mb-4">📝</div>
|
||||
<p class="text-[#b9b9b9] text-lg font-medium mb-2">
|
||||
{searchTerm() || selectedTags().length > 0
|
||||
? 'No notes found matching your search or filters.'
|
||||
: 'No notes yet. Add your first note!'}
|
||||
</p>
|
||||
<p class="text-[#8e9297] text-sm">
|
||||
{searchTerm() || selectedTags().length > 0
|
||||
? 'Try adjusting your search terms or filters.'
|
||||
: 'Click the "Add Note" button to get started.'}
|
||||
</p>
|
||||
<IconCopy size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
exportNote(note);
|
||||
}}
|
||||
class="text-muted-foreground hover:text-foreground p-1"
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditNote(note);
|
||||
}}
|
||||
class="text-muted-foreground hover:text-foreground p-1"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePin(note.id);
|
||||
}}
|
||||
class="text-primary hover:text-primary/80 p-1"
|
||||
{...{ title: note.pinned ? 'Unpin note' : 'Pin note' }}
|
||||
>
|
||||
<IconPin size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNote(note.id);
|
||||
}}
|
||||
class="text-destructive hover:text-destructive/80 p-1"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
<div class="text-muted-foreground text-sm mb-3">
|
||||
<div class={expandedNotes().has(note.id) ? '' : 'max-h-72 overflow-hidden'}>
|
||||
<NoteContentRenderer
|
||||
content={note.content}
|
||||
kind={getNoteKind(note)}
|
||||
preview={!expandedNotes().has(note.id)}
|
||||
maxBlocks={4}
|
||||
onToggleTask={(taskIndex, nextChecked) => updateNoteCheckbox(note.id, taskIndex, nextChecked)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleNoteExpansion(note.id);
|
||||
}}
|
||||
class="mt-2 text-xs text-primary hover:text-primary/80 font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
{expandedNotes().has(note.id) ? 'Show less ←' : 'Show more →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={note.attachments && note.attachments.length > 0}>
|
||||
<div class="mb-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<IconPaperclip class="size-4 text-muted-foreground" />
|
||||
<span class="text-xs text-muted-foreground">Attachments ({note.attachments?.length || 0})</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={note.attachments || []}>
|
||||
{(attachment) => (
|
||||
<div class="flex items-center gap-2 px-2 py-1 bg-muted rounded-md text-xs">
|
||||
<span class="text-foreground">{attachment.name}</span>
|
||||
<span class="text-muted-foreground">({attachment.size})</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<For each={note.tags}>
|
||||
{(tag) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleTag(tag);
|
||||
}}
|
||||
class="px-2 py-1 bg-muted hover:bg-muted/80 text-muted-foreground hover:text-foreground text-xs rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Updated: {formatDisplayDate(note.updatedAt)}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</For>
|
||||
|
||||
<Show when={filteredNotes().length === 0}>
|
||||
<Card class="p-12 text-center">
|
||||
<p class="text-muted-foreground">
|
||||
{searchTerm() || selectedTags().length > 0
|
||||
? 'No notes found matching your search or filters.'
|
||||
: 'No notes yet. Add your first note!'}
|
||||
</p>
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Add Note Modal */}
|
||||
<NoteModal
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import { createSignal, onMount, Show, For } from 'solid-js';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { IconUser, IconLock, IconKey, IconBrain, IconMail, IconSend, IconShield, IconDownload } from '@tabler/icons-solidjs';
|
||||
import { TwoFactorAuth } from '@/components/TwoFactorAuth';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { AIProviderIcon } from '@/components/AIProviderIcon';
|
||||
import { useHaptics } from '@/lib/haptics';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
|
||||
interface BrowserExtensionApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
is_active: boolean;
|
||||
last_used?: string;
|
||||
}
|
||||
|
||||
interface BrowserExtensionClient {
|
||||
id: number;
|
||||
extension_id: string;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
last_seen?: string;
|
||||
}
|
||||
|
||||
export const Settings = () => {
|
||||
const { authState, updateProfile, changePassword } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const haptics = useHaptics();
|
||||
const apiBaseUrl = getApiV1BaseUrl();
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [message, setMessage] = createSignal('');
|
||||
const [profileData, setProfileData] = createSignal({
|
||||
@@ -128,6 +148,8 @@ export const Settings = () => {
|
||||
const [emailSettingsExpanded, setEmailSettingsExpanded] = createSignal(true);
|
||||
const [aiLoading, setAiLoading] = createSignal(false);
|
||||
const [activeTab, setActiveTab] = createSignal('account');
|
||||
const [browserExtensionApiKeys, setBrowserExtensionApiKeys] = createSignal<BrowserExtensionApiKey[]>([]);
|
||||
const [browserExtensions, setBrowserExtensions] = createSignal<BrowserExtensionClient[]>([]);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'account', name: 'Account', icon: IconUser },
|
||||
@@ -168,6 +190,7 @@ export const Settings = () => {
|
||||
loadAISettings();
|
||||
loadAvailableAIProviders();
|
||||
loadSearchSettings();
|
||||
loadBrowserExtensionAccess();
|
||||
});
|
||||
|
||||
|
||||
@@ -235,6 +258,34 @@ export const Settings = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadBrowserExtensionAccess = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
};
|
||||
|
||||
const [apiKeysResponse, extensionsResponse] = await Promise.all([
|
||||
fetch(`${apiBaseUrl}/browser-extension/api-keys`, { headers }),
|
||||
fetch(`${apiBaseUrl}/browser-extension/extensions`, { headers }),
|
||||
]);
|
||||
|
||||
if (apiKeysResponse.ok) {
|
||||
const keys = await apiKeysResponse.json();
|
||||
setBrowserExtensionApiKeys(Array.isArray(keys) ? keys : []);
|
||||
}
|
||||
|
||||
if (extensionsResponse.ok) {
|
||||
const extensions = await extensionsResponse.json();
|
||||
setBrowserExtensions(Array.isArray(extensions) ? extensions : []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load browser extension access:', error);
|
||||
setBrowserExtensionApiKeys([]);
|
||||
setBrowserExtensions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateAISettings = async () => {
|
||||
setAiLoading(true);
|
||||
setMessage('');
|
||||
@@ -1685,7 +1736,6 @@ export const Settings = () => {
|
||||
{/* Tools Tab */}
|
||||
<Show when={activeTab() === 'tools'}>
|
||||
<div class="space-y-6">
|
||||
{/* Browser Extension Download */}
|
||||
<div class="border rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<IconDownload class="size-5" />
|
||||
@@ -1693,13 +1743,72 @@ export const Settings = () => {
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Download the Trackeep browser extension for seamless bookmarking and productivity.
|
||||
The extension authenticates with a Trackeep browser-extension API key. Download the extension, then connect it with a key from your account.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.14em] text-muted-foreground mb-1">Active API Keys</p>
|
||||
<p class="text-2xl font-semibold text-foreground">
|
||||
{browserExtensionApiKeys().filter((key) => key.is_active).length}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{browserExtensionApiKeys()[0]?.name || 'No extension key created yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.14em] text-muted-foreground mb-1">Connected Extensions</p>
|
||||
<p class="text-2xl font-semibold text-foreground">
|
||||
{browserExtensions().filter((extension) => extension.is_active).length}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{browserExtensions()[0]?.name || 'No extension connected yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border/70 bg-muted/20 p-4">
|
||||
<p class="text-xs uppercase tracking-[0.14em] text-muted-foreground mb-1">Last Activity</p>
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{browserExtensions()[0]?.last_seen
|
||||
? new Date(browserExtensions()[0].last_seen as string).toLocaleString()
|
||||
: browserExtensionApiKeys()[0]?.last_used
|
||||
? new Date(browserExtensionApiKeys()[0].last_used as string).toLocaleString()
|
||||
: 'No extension activity yet'}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Paste an API key into the extension options after installation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-primary/20 bg-primary/5 p-4">
|
||||
<h4 class="font-medium text-foreground mb-2">Connection flow</h4>
|
||||
<div class="space-y-2 text-sm text-muted-foreground">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-primary">1.</span>
|
||||
<span>Download and unpack the extension.</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-primary">2.</span>
|
||||
<span>Create or reuse a browser-extension API key in Trackeep.</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-primary">3.</span>
|
||||
<span>Paste that key into the extension settings to connect bookmarks, files, notes, and tasks.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={() => navigate('/app/browser-extension')}
|
||||
variant="default"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<IconKey class="size-4" />
|
||||
Manage API Keys
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Create a download link for the extension zip
|
||||
const link = document.createElement('a');
|
||||
link.href = '/browser-extension';
|
||||
link.download = 'browser-extension.zip';
|
||||
@@ -1707,14 +1816,13 @@ export const Settings = () => {
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}}
|
||||
variant="default"
|
||||
variant="outline"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<IconDownload class="size-4" />
|
||||
Download Extension (ZIP)
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="bg-muted/30 rounded-lg p-4">
|
||||
<h4 class="font-medium text-foreground mb-2">Installation Instructions:</h4>
|
||||
<div class="space-y-2 text-sm text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { toast } from '@/components/ui/Toast';
|
||||
import { getApiV1BaseUrl } from '@/lib/api-url';
|
||||
import { useNavigate } from '@solidjs/router';
|
||||
import { createSignal, onMount, Show } from 'solid-js';
|
||||
|
||||
const API_BASE_URL = getApiV1BaseUrl();
|
||||
const PENDING_SHARE_KEY = 'trackeep_pending_share_target';
|
||||
|
||||
interface PendingSharePayload {
|
||||
title: string;
|
||||
text: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function extractFirstUrl(text: string): string | null {
|
||||
const match = text.match(/https?:\/\/[^\s<>"')\]]+/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match[0].replace(/[),.;!?]+$/, '');
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string | null {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(trimmed).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveTitle(url: string, providedTitle: string): string {
|
||||
const title = providedTitle.trim();
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return 'Shared Link';
|
||||
}
|
||||
}
|
||||
|
||||
function isGenericTitle(title: string, url: string): boolean {
|
||||
const normalized = title.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized === 'shared link' || normalized === 'youtube video') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase().replace(/^www\./, '');
|
||||
return normalized === host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveDescription(sharedText: string, url: string): string {
|
||||
const cleaned = sharedText.replace(url, '').trim();
|
||||
return cleaned.slice(0, 1200);
|
||||
}
|
||||
|
||||
function looksLikeYouTube(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase();
|
||||
return host.includes('youtube.com') || host.includes('youtu.be');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writePendingShare(payload: PendingSharePayload): void {
|
||||
sessionStorage.setItem(PENDING_SHARE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function readPendingShare(): PendingSharePayload | null {
|
||||
const raw = sessionStorage.getItem(PENDING_SHARE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<PendingSharePayload>;
|
||||
return {
|
||||
title: typeof parsed.title === 'string' ? parsed.title : '',
|
||||
text: typeof parsed.text === 'string' ? parsed.text : '',
|
||||
url: typeof parsed.url === 'string' ? parsed.url : '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingShare(): void {
|
||||
sessionStorage.removeItem(PENDING_SHARE_KEY);
|
||||
}
|
||||
|
||||
export const ShareTarget = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = createSignal<'processing' | 'success' | 'error' | 'auth'>('processing');
|
||||
const [message, setMessage] = createSignal('Saving shared content to Trackeep...');
|
||||
|
||||
onMount(async () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const incomingPayload: PendingSharePayload = {
|
||||
title: params.get('title') || '',
|
||||
text: params.get('text') || '',
|
||||
url: params.get('url') || '',
|
||||
};
|
||||
|
||||
const hasIncomingPayload = Boolean(incomingPayload.title || incomingPayload.text || incomingPayload.url);
|
||||
if (hasIncomingPayload) {
|
||||
writePendingShare(incomingPayload);
|
||||
}
|
||||
|
||||
const payload = hasIncomingPayload ? incomingPayload : readPendingShare();
|
||||
if (!payload) {
|
||||
setStatus('error');
|
||||
setMessage('No shared content detected.');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('trackeep_token') || localStorage.getItem('token');
|
||||
if (!token) {
|
||||
setStatus('auth');
|
||||
setMessage('Sign in required. Redirecting to login...');
|
||||
toast.info('Sign in required', 'Please sign in to save shared content.');
|
||||
navigate('/login?next=%2Fshare-target', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawUrl = payload.url || extractFirstUrl(payload.text) || '';
|
||||
const normalizedUrl = normalizeUrl(rawUrl);
|
||||
if (!normalizedUrl) {
|
||||
setStatus('error');
|
||||
setMessage('No valid URL found in shared content.');
|
||||
toast.error('Share failed', 'No valid URL found in shared payload.');
|
||||
clearPendingShare();
|
||||
return;
|
||||
}
|
||||
|
||||
let title = deriveTitle(normalizedUrl, payload.title);
|
||||
let description = deriveDescription(payload.text, normalizedUrl);
|
||||
|
||||
try {
|
||||
const metadataResponse = await fetch(`${API_BASE_URL}/bookmarks/metadata`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ url: normalizedUrl }),
|
||||
});
|
||||
|
||||
if (metadataResponse.ok) {
|
||||
const metadata = await metadataResponse
|
||||
.json()
|
||||
.catch(() => ({})) as { title?: string; description?: string };
|
||||
if (
|
||||
typeof metadata.title === 'string' &&
|
||||
metadata.title.trim().length > 0 &&
|
||||
isGenericTitle(title, normalizedUrl)
|
||||
) {
|
||||
title = metadata.title.trim();
|
||||
}
|
||||
|
||||
if (
|
||||
typeof metadata.description === 'string' &&
|
||||
metadata.description.trim().length > 0 &&
|
||||
description.trim().length < 12
|
||||
) {
|
||||
description = metadata.description.trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// metadata enrichment is best-effort
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/bookmarks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
url: normalizedUrl,
|
||||
description,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
clearPendingShare();
|
||||
setStatus('success');
|
||||
const destination = looksLikeYouTube(normalizedUrl) ? '/app/youtube' : '/app/bookmarks';
|
||||
setMessage(
|
||||
looksLikeYouTube(normalizedUrl)
|
||||
? 'Saved successfully. Redirecting to YouTube...'
|
||||
: 'Saved successfully. Redirecting to bookmarks...',
|
||||
);
|
||||
toast.success('Saved to Trackeep', 'Shared link was added to bookmarks.');
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(destination, { replace: true });
|
||||
}, 600);
|
||||
} catch (error) {
|
||||
const text = error instanceof Error ? error.message : 'Failed to save shared content';
|
||||
setStatus('error');
|
||||
setMessage(text);
|
||||
toast.error('Share failed', text);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="min-h-[50vh] flex items-center justify-center p-4">
|
||||
<Card class="w-full max-w-md p-6 space-y-4 border-border bg-card text-card-foreground">
|
||||
<h1 class="text-xl font-semibold">Trackeep Share Target</h1>
|
||||
<p class="text-sm text-muted-foreground">{message()}</p>
|
||||
|
||||
<Show when={status() === 'processing'}>
|
||||
<div class="inline-block w-6 h-6 border-2 border-primary border-r-transparent rounded-full animate-spin" />
|
||||
</Show>
|
||||
|
||||
<Show when={status() !== 'processing'}>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
onClick={() => navigate(status() === 'auth' ? '/login' : '/app/bookmarks', { replace: true })}
|
||||
>
|
||||
{status() === 'auth' ? 'Open Login' : 'Open Bookmarks'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => navigate('/app', { replace: true })}>
|
||||
Go Home
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user