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:
parent
5cf9751b41
commit
0bc8bf1fe9
18 changed files with 100 additions and 108 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue