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">
const { baseURL } = useRuntimeConfig().app;
</script>
<template>
<NuxtLink to="/" class="app-logo">
<img
src="/logo-192.png"
:src="`${baseURL}logo-192.png`"
alt="Claude Agent Teams"
class="app-logo__img"
width="36"

View file

@ -9,6 +9,13 @@ const switchLocalePath = useSwitchLocalePath();
const props = defineProps<{ fullWidth?: boolean; compact?: boolean; iconOnly?: boolean }>();
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> = {
en: "circle-flags:us",
ru: "circle-flags:ru"

View file

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

View file

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

View file

@ -16,15 +16,19 @@ export const useLocation = () => {
};
const initLocale = () => {
// Sync store with actual i18n locale (already resolved from route by nuxt-i18n)
const currentLocale = i18n?.locale?.value || "en";
if (cookie.value) {
localeStore.setLocale(cookie.value, false);
if (i18n?.setLocale) {
i18n.setLocale(cookie.value);
} else if (i18n?.locale?.value) {
i18n.locale.value = cookie.value;
// Cookie exists — sync store, but don't override route-based locale
localeStore.setLocale(currentLocale, false);
if (cookie.value !== currentLocale) {
cookie.value = currentLocale;
}
return;
}
// No cookie — detect from browser and set
const detected = getBrowserLocale();
localeStore.setLocale(detected, false);
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 githubRepo = process.env.NUXT_PUBLIC_GITHUB_REPO || "777genius/claude_agent_teams_ui";
const githubReleasesUrl = `https://github.com/${githubRepo}/releases`;
const baseURL = process.env.NUXT_APP_BASE_URL || "/";
export default defineNuxtConfig({
compatibilityDate: "2026-01-19",
@ -15,9 +16,10 @@ export default defineNuxtConfig({
inlineSSRStyles: false
},
app: {
baseURL,
head: {
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.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" },

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 { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';

View file

@ -1,10 +1,8 @@
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 { isImageMime } from '@renderer/utils/attachmentUtils';
import { Loader2 } from 'lucide-react';
import { AttachmentThumbnail } from './AttachmentThumbnail';
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 { Ban, X } from 'lucide-react';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { AttachmentThumbnail } from './AttachmentThumbnail';
import type { AttachmentPayload } from '@shared/types';

View file

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

View file

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

View file

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

View file

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

View file

@ -17,13 +17,13 @@ import {
type ComposerDraftSnapshot,
composerDraftStorage,
} from '@renderer/services/composerDraftStorage';
import { categorizeFile } from '@shared/constants/attachments';
import {
fileToAttachmentPayload,
MAX_FILES,
MAX_TOTAL_SIZE,
validateAttachment,
} from '@renderer/utils/attachmentUtils';
import { categorizeFile } from '@shared/constants/attachments';
import type { InlineChip } from '@renderer/types/inlineChip';
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';
@ -35,29 +35,6 @@ function buildTeamMentionEntries(teams: readonly TeamSummary[]): TeamMentionEntr
.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 {
if (entries.length === 0) {
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 {
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
// render while still returning a referentially-stable value when the
// underlying data hasn't changed.
const meta = useMemo(() => buildTeamMentionMeta(entries), [entries]);
if (
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;
return meta;
}