feat(landing): enhance base URL handling and improve image paths

- Introduced baseURL configuration to dynamically set asset paths in the landing components.
- Updated AppLogo and HeroSection components to use baseURL for logo image sources.
- Refactored ScreenshotsSection to utilize a publicPath function for consistent image path handling.
- Improved LanguageSwitcher to synchronize the i18n locale with the store on mount.
- Enhanced TaskCommentInput to handle file uploads more robustly, including validation for empty files and improved error handling.
- Adjusted MessageComposer to conditionally support attachments based on team status.
This commit is contained in:
iliya 2026-03-23 17:51:09 +02:00
parent 5cf9751b41
commit 0bc8bf1fe9
18 changed files with 100 additions and 108 deletions

View file

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
const { baseURL } = useRuntimeConfig().app;
</script> </script>
<template> <template>
<NuxtLink to="/" class="app-logo"> <NuxtLink to="/" class="app-logo">
<img <img
src="/logo-192.png" :src="`${baseURL}logo-192.png`"
alt="Claude Agent Teams" alt="Claude Agent Teams"
class="app-logo__img" class="app-logo__img"
width="36" width="36"

View file

@ -9,6 +9,13 @@ const switchLocalePath = useSwitchLocalePath();
const props = defineProps<{ fullWidth?: boolean; compact?: boolean; iconOnly?: boolean }>(); const props = defineProps<{ fullWidth?: boolean; compact?: boolean; iconOnly?: boolean }>();
const localeStore = useLocaleStore(); const localeStore = useLocaleStore();
// Sync store with actual i18n locale on mount (handles SSG hydration)
onMounted(() => {
if (locale.value && locale.value !== localeStore.current) {
localeStore.setLocale(locale.value as string, false);
}
});
const flagIconMap: Record<string, string> = { const flagIconMap: Record<string, string> = {
en: "circle-flags:us", en: "circle-flags:us",
ru: "circle-flags:ru" ru: "circle-flags:ru"

View file

@ -3,6 +3,7 @@ import { mdiRobotOutline, mdiViewDashboardOutline, mdiOpenSourceInitiative } fro
const { content } = useLandingContent(); const { content } = useLandingContent();
const { t } = useI18n(); const { t } = useI18n();
const { baseURL } = useRuntimeConfig().app;
</script> </script>
<template> <template>
@ -13,7 +14,7 @@ const { t } = useI18n();
<v-col cols="12" md="6" class="hero-section__content"> <v-col cols="12" md="6" class="hero-section__content">
<h1 class="hero-section__title"> <h1 class="hero-section__title">
<img <img
src="/logo-192.png" :src="`${baseURL}logo-192.png`"
alt="" alt=""
class="hero-section__logo" class="hero-section__logo"
width="56" width="56"

View file

@ -4,19 +4,22 @@ import { register } from 'swiper/element/bundle';
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiArrowExpand } from '@mdi/js'; import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiArrowExpand } from '@mdi/js';
const { t } = useI18n(); const { t } = useI18n();
const { baseURL } = useRuntimeConfig().app;
register(); register();
const publicPath = (path: string) => `${baseURL}${path.replace(/^\//, '')}`;
const screenshots = [ const screenshots = [
{ src: '/screenshots/1.jpg', alt: 'Kanban board with agent tasks' }, { src: publicPath('/screenshots/1.jpg'), alt: 'Kanban board with agent tasks' },
{ src: '/screenshots/2.jpg', alt: 'Agent team communication' }, { src: publicPath('/screenshots/2.jpg'), alt: 'Agent team communication' },
{ src: '/screenshots/3.png', alt: 'Code review diff view' }, { src: publicPath('/screenshots/3.png'), alt: 'Code review diff view' },
{ src: '/screenshots/4.png', alt: 'Team management dashboard' }, { src: publicPath('/screenshots/4.png'), alt: 'Team management dashboard' },
{ src: '/screenshots/5.png', alt: 'Live process monitoring' }, { src: publicPath('/screenshots/5.png'), alt: 'Live process monitoring' },
{ src: '/screenshots/6.png', alt: 'Session context analysis' }, { src: publicPath('/screenshots/6.png'), alt: 'Session context analysis' },
{ src: '/screenshots/7.png', alt: 'Cross-team messaging' }, { src: publicPath('/screenshots/7.png'), alt: 'Cross-team messaging' },
{ src: '/screenshots/8.png', alt: 'Task details and comments' }, { src: publicPath('/screenshots/8.png'), alt: 'Task details and comments' },
{ src: '/screenshots/9.png', alt: 'Built-in code editor' }, { src: publicPath('/screenshots/9.png'), alt: 'Built-in code editor' },
]; ];
const swiperRef = ref<HTMLElement | null>(null); const swiperRef = ref<HTMLElement | null>(null);

View file

@ -16,15 +16,19 @@ export const useLocation = () => {
}; };
const initLocale = () => { const initLocale = () => {
// Sync store with actual i18n locale (already resolved from route by nuxt-i18n)
const currentLocale = i18n?.locale?.value || "en";
if (cookie.value) { if (cookie.value) {
localeStore.setLocale(cookie.value, false); // Cookie exists — sync store, but don't override route-based locale
if (i18n?.setLocale) { localeStore.setLocale(currentLocale, false);
i18n.setLocale(cookie.value); if (cookie.value !== currentLocale) {
} else if (i18n?.locale?.value) { cookie.value = currentLocale;
i18n.locale.value = cookie.value;
} }
return; return;
} }
// No cookie — detect from browser and set
const detected = getBrowserLocale(); const detected = getBrowserLocale();
localeStore.setLocale(detected, false); localeStore.setLocale(detected, false);
if (i18n?.setLocale) { if (i18n?.setLocale) {

View file

@ -7,6 +7,7 @@ declare const process: any;
const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://claude-agent-teams.dev"; const siteUrl = process.env.NUXT_PUBLIC_SITE_URL || "https://claude-agent-teams.dev";
const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui"; const githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui";
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`; const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2026-01-19", compatibilityDate: "2026-01-19",
@ -15,9 +16,10 @@ export default defineNuxtConfig({
inlineSSRStyles: false inlineSSRStyles: false
}, },
app: { app: {
baseURL,
head: { head: {
link: [ link: [
{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }, { rel: "icon", type: "image/x-icon", href: `${baseURL}favicon.ico` },
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" }, { rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "" },
{ rel: "preload", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap", as: "style" }, { rel: "preload", href: "https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap", as: "style" },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -1,7 +1,6 @@
import { createHash } from 'crypto';
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createHash } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';

View file

@ -1,10 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { isImageMime } from '@renderer/utils/attachmentUtils';
import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { isImageMime } from '@renderer/utils/attachmentUtils';
import { Loader2 } from 'lucide-react';
import { AttachmentThumbnail } from './AttachmentThumbnail'; import { AttachmentThumbnail } from './AttachmentThumbnail';
import { ImageLightbox } from './ImageLightbox'; import { ImageLightbox } from './ImageLightbox';

View file

@ -1,8 +1,7 @@
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { formatFileSize, isImageMime } from '@renderer/utils/attachmentUtils'; import { formatFileSize, isImageMime } from '@renderer/utils/attachmentUtils';
import { Ban, X } from 'lucide-react'; import { Ban, X } from 'lucide-react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { AttachmentThumbnail } from './AttachmentThumbnail'; import { AttachmentThumbnail } from './AttachmentThumbnail';
import type { AttachmentPayload } from '@shared/types'; import type { AttachmentPayload } from '@shared/types';

View file

@ -1,8 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { AlertCircle, X } from 'lucide-react';
import { isImageMime } from '@renderer/utils/attachmentUtils'; import { isImageMime } from '@renderer/utils/attachmentUtils';
import { AlertCircle, X } from 'lucide-react';
import { AttachmentPreviewItem } from './AttachmentPreviewItem'; import { AttachmentPreviewItem } from './AttachmentPreviewItem';
import { ImageLightbox } from './ImageLightbox'; import { ImageLightbox } from './ImageLightbox';

View file

@ -124,6 +124,7 @@ export const SendMessageDialog = ({
addFiles, addFiles,
removeAttachment, removeAttachment,
clearAttachments, clearAttachments,
clearError: clearAttachmentError,
handlePaste, handlePaste,
handleDrop, handleDrop,
} = useAttachments({ persistenceKey: `sendMessage:${teamName}:attachments` }); } = useAttachments({ persistenceKey: `sendMessage:${teamName}:attachments` });
@ -417,6 +418,7 @@ export const SendMessageDialog = ({
attachments={attachments} attachments={attachments}
onRemove={removeAttachment} onRemove={removeAttachment}
error={attachmentError ?? fileRestrictionError} error={attachmentError ?? fileRestrictionError}
onDismissError={clearAttachmentError}
disabled={attachmentsBlocked} disabled={attachmentsBlocked}
disabledHint="File attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient." disabledHint="File attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/> />

View file

@ -88,12 +88,14 @@ export const TaskCommentInput = ({
!addingComment; !addingComment;
const addFiles = useCallback( const addFiles = useCallback(
(files: FileList | File[]) => { async (files: FileList | File[]) => {
setAttachError(null); setAttachError(null);
const fileArray = Array.from(files); const fileArray = Array.from(files);
// 1. Separate unsupported files → path prepend
const supported: File[] = [];
for (const file of fileArray) { for (const file of fileArray) {
if (categorizeFile(file) === 'unsupported') { if (categorizeFile(file) === 'unsupported') {
// Insert absolute file path into comment text for unsupported types
const filePath = (file as { path?: string }).path; const filePath = (file as { path?: string }).path;
if (filePath) { if (filePath) {
const current = draft.value; const current = draft.value;
@ -101,37 +103,51 @@ export const TaskCommentInput = ({
} }
continue; continue;
} }
if (file.size === 0) {
setAttachError(`File "${file.name}" is empty`);
continue;
}
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
setAttachError( setAttachError(
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)` `File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
); );
continue; continue;
} }
const reader = new FileReader(); supported.push(file);
reader.onload = () => { }
const result = reader.result as string;
const base64 = result.split(',')[1]; if (supported.length === 0) return;
if (!base64) return;
const id = crypto.randomUUID(); // 2. Read all files sequentially to avoid race condition with MAX_ATTACHMENTS
setPendingAttachments((prev) => { for (const file of supported) {
if (prev.length >= MAX_ATTACHMENTS) { const result = await new Promise<string | null>((resolve) => {
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); const reader = new FileReader();
return prev; reader.onload = () => resolve(reader.result as string);
} reader.onerror = () => resolve(null);
return [ reader.readAsDataURL(file);
...prev, });
{ if (!result) continue;
id, const base64 = result.split(',')[1];
filename: file.name, if (!base64) continue;
mimeType: getEffectiveMimeType(file),
base64Data: base64, const id = crypto.randomUUID();
previewUrl: result, setPendingAttachments((prev) => {
size: file.size, if (prev.length >= MAX_ATTACHMENTS) {
}, setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
]; return prev;
}); }
}; return [
reader.readAsDataURL(file); ...prev,
{
id,
filename: file.name,
mimeType: getEffectiveMimeType(file),
base64Data: base64,
previewUrl: result,
size: file.size,
},
];
});
} }
}, },
[draft] [draft]
@ -194,12 +210,12 @@ export const TaskCommentInput = ({
for (const item of Array.from(items)) { for (const item of Array.from(items)) {
if (item.kind === 'file') { if (item.kind === 'file') {
const file = item.getAsFile(); const file = item.getAsFile();
if (file && categorizeFile(file) !== 'unsupported') pastedFiles.push(file); if (file) pastedFiles.push(file);
} }
} }
if (pastedFiles.length > 0) { if (pastedFiles.length > 0) {
e.preventDefault(); e.preventDefault();
addFiles(pastedFiles); void addFiles(pastedFiles);
} }
}, },
[addFiles] [addFiles]
@ -318,7 +334,7 @@ export const TaskCommentInput = ({
multiple multiple
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
if (e.target.files) addFiles(e.target.files); if (e.target.files) void addFiles(e.target.files);
e.target.value = ''; e.target.value = '';
}} }}

View file

@ -255,7 +255,7 @@ export const MessageComposer = ({
// const leadContext = useStore((s) => // const leadContext = useStore((s) =>
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
// ); // );
const supportsAttachments = isLeadRecipient && !isCrossTeam; const supportsAttachments = isLeadRecipient && !isCrossTeam && !!isTeamAlive;
const canAttach = supportsAttachments && draft.canAddMore; const canAttach = supportsAttachments && draft.canAddMore;
const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments; const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments;
const canSend = const canSend =
@ -370,22 +370,22 @@ export const MessageComposer = ({
e.preventDefault(); e.preventDefault();
dragCounterRef.current = 0; dragCounterRef.current = 0;
setIsDragOver(false); setIsDragOver(false);
if (!isLeadRecipient) { if (!supportsAttachments) {
const files = e.dataTransfer?.files; const files = e.dataTransfer?.files;
if (files?.length) { if (files?.length) {
showFileRestrictionError(); showFileRestrictionError();
} }
return; return;
} }
if (canAttach) draftHandleDrop(e); draftHandleDrop(e);
}, },
[isLeadRecipient, canAttach, draftHandleDrop, showFileRestrictionError] [supportsAttachments, draftHandleDrop, showFileRestrictionError]
); );
const { handlePaste: draftHandlePaste } = draft; const { handlePaste: draftHandlePaste } = draft;
const handlePasteWrapper = useCallback( const handlePasteWrapper = useCallback(
(e: React.ClipboardEvent) => { (e: React.ClipboardEvent) => {
if (!isLeadRecipient) { if (!supportsAttachments) {
const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file'); const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file');
if (hasFiles) { if (hasFiles) {
e.preventDefault(); e.preventDefault();
@ -393,9 +393,9 @@ export const MessageComposer = ({
} }
return; return;
} }
if (canAttach) draftHandlePaste(e); draftHandlePaste(e);
}, },
[isLeadRecipient, canAttach, draftHandlePaste, showFileRestrictionError] [supportsAttachments, draftHandlePaste, showFileRestrictionError]
); );
const remaining = MAX_TEXT_LENGTH - trimmed.length; const remaining = MAX_TEXT_LENGTH - trimmed.length;

View file

@ -1,13 +1,13 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { draftStorage } from '@renderer/services/draftStorage'; import { draftStorage } from '@renderer/services/draftStorage';
import { categorizeFile } from '@shared/constants/attachments';
import { import {
fileToAttachmentPayload, fileToAttachmentPayload,
MAX_FILES, MAX_FILES,
MAX_TOTAL_SIZE, MAX_TOTAL_SIZE,
validateAttachment, validateAttachment,
} from '@renderer/utils/attachmentUtils'; } from '@renderer/utils/attachmentUtils';
import { categorizeFile } from '@shared/constants/attachments';
import type { AttachmentPayload } from '@shared/types'; import type { AttachmentPayload } from '@shared/types';

View file

@ -17,13 +17,13 @@ import {
type ComposerDraftSnapshot, type ComposerDraftSnapshot,
composerDraftStorage, composerDraftStorage,
} from '@renderer/services/composerDraftStorage'; } from '@renderer/services/composerDraftStorage';
import { categorizeFile } from '@shared/constants/attachments';
import { import {
fileToAttachmentPayload, fileToAttachmentPayload,
MAX_FILES, MAX_FILES,
MAX_TOTAL_SIZE, MAX_TOTAL_SIZE,
validateAttachment, validateAttachment,
} from '@renderer/utils/attachmentUtils'; } from '@renderer/utils/attachmentUtils';
import { categorizeFile } from '@shared/constants/attachments';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
import type { AgentActionMode, AttachmentPayload } from '@shared/types'; import type { AgentActionMode, AttachmentPayload } from '@shared/types';

View file

@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react'; import { useMemo } from 'react';
import type { TeamSummary } from '@shared/types'; import type { TeamSummary } from '@shared/types';
@ -35,29 +35,6 @@ function buildTeamMentionEntries(teams: readonly TeamSummary[]): TeamMentionEntr
.sort(compareTeamMentionEntries); .sort(compareTeamMentionEntries);
} }
function areTeamMentionEntriesEqual(
prev: readonly TeamMentionEntry[],
next: readonly TeamMentionEntry[]
): boolean {
if (prev === next) return true;
if (prev.length !== next.length) return false;
for (let i = 0; i < prev.length; i++) {
const prevEntry = prev[i];
const nextEntry = next[i];
if (
prevEntry.teamName !== nextEntry.teamName ||
prevEntry.displayName !== nextEntry.displayName ||
prevEntry.color !== nextEntry.color ||
prevEntry.deletedAt !== nextEntry.deletedAt
) {
return false;
}
}
return true;
}
function buildTeamMentionMeta(entries: readonly TeamMentionEntry[]): TeamMentionMeta { function buildTeamMentionMeta(entries: readonly TeamMentionEntry[]): TeamMentionMeta {
if (entries.length === 0) { if (entries.length === 0) {
return { teamNames: EMPTY_TEAM_NAMES, teamColorByName: EMPTY_TEAM_COLOR_MAP }; return { teamNames: EMPTY_TEAM_NAMES, teamColorByName: EMPTY_TEAM_COLOR_MAP };
@ -84,24 +61,8 @@ function buildTeamMentionMeta(entries: readonly TeamMentionEntry[]): TeamMention
export function useStableTeamMentionMeta(teams: readonly TeamSummary[]): TeamMentionMeta { export function useStableTeamMentionMeta(teams: readonly TeamSummary[]): TeamMentionMeta {
const entries = useMemo(() => buildTeamMentionEntries(teams), [teams]); const entries = useMemo(() => buildTeamMentionEntries(teams), [teams]);
const stableRef = useRef<{ entries: readonly TeamMentionEntry[]; value: TeamMentionMeta } | null>(
null
);
// Intentional ref-as-cache pattern: avoids allocating a new object every const meta = useMemo(() => buildTeamMentionMeta(entries), [entries]);
// render while still returning a referentially-stable value when the
// underlying data hasn't changed.
if ( return meta;
stableRef.current === null ||
!areTeamMentionEntriesEqual(stableRef.current.entries, entries)
) {
stableRef.current = {
entries,
value: buildTeamMentionMeta(entries),
};
}
// eslint-disable-next-line react-hooks/refs -- stable ref cache pattern
return stableRef.current.value;
} }