Show colored icons for projects without an icon
The letter-on-grey placeholder is replaced by a fill whose color is a deterministic hash of the project key, so each project keeps a stable color across the sidebar, projects list, and settings header. Mid- saturation tones keep the white letter legible in both themes.
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
type ImageStyle,
|
||||||
|
type StyleProp,
|
||||||
|
Text,
|
||||||
|
type TextStyle,
|
||||||
|
View,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import { deriveProjectIconColor } from "@/utils/project-icon-color";
|
||||||
|
|
||||||
|
const WHITE_TEXT = { color: "#ffffff" } as const;
|
||||||
|
|
||||||
|
export function ProjectIconView({
|
||||||
|
iconDataUri,
|
||||||
|
initial,
|
||||||
|
projectKey,
|
||||||
|
imageStyle,
|
||||||
|
fallbackStyle,
|
||||||
|
textStyle,
|
||||||
|
}: {
|
||||||
|
iconDataUri: string | null;
|
||||||
|
initial: string;
|
||||||
|
projectKey: string;
|
||||||
|
imageStyle: StyleProp<ImageStyle>;
|
||||||
|
fallbackStyle: StyleProp<ViewStyle>;
|
||||||
|
textStyle: StyleProp<TextStyle>;
|
||||||
|
}) {
|
||||||
|
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
||||||
|
const fallbackStyles = useMemo(
|
||||||
|
() => [fallbackStyle, { backgroundColor: deriveProjectIconColor(projectKey) }],
|
||||||
|
[fallbackStyle, projectKey],
|
||||||
|
);
|
||||||
|
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
|
||||||
|
|
||||||
|
if (iconDataUri) {
|
||||||
|
return <Image source={imageSource} style={imageStyle} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={fallbackStyles}>
|
||||||
|
<Text style={textStyles}>{initial}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
Pressable,
|
Pressable,
|
||||||
Image,
|
|
||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
import * as Haptics from "expo-haptics";
|
import * as Haptics from "expo-haptics";
|
||||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||||
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
|
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
|
||||||
|
import { ProjectIconView } from "@/components/project-icon-view";
|
||||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||||
import {
|
import {
|
||||||
@@ -57,7 +57,7 @@ import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
|||||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
import { projectIconQueryKey, projectIconToDataUri } from "@/hooks/use-project-icon-query";
|
||||||
import {
|
import {
|
||||||
buildHostNewWorkspaceRoute,
|
buildHostNewWorkspaceRoute,
|
||||||
buildProjectSettingsRoute,
|
buildProjectSettingsRoute,
|
||||||
@@ -119,13 +119,6 @@ import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
|||||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
|
|
||||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
|
||||||
if (!icon) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return `data:${icon.mimeType};base64,${icon.data}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
|
||||||
|
|
||||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||||
@@ -471,6 +464,7 @@ function ProjectLeadingVisual({
|
|||||||
displayName,
|
displayName,
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
workspace,
|
workspace,
|
||||||
|
projectKey,
|
||||||
chevron = null,
|
chevron = null,
|
||||||
showChevron = false,
|
showChevron = false,
|
||||||
isArchiving = false,
|
isArchiving = false,
|
||||||
@@ -478,6 +472,7 @@ function ProjectLeadingVisual({
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
iconDataUri: string | null;
|
iconDataUri: string | null;
|
||||||
workspace: SidebarWorkspaceEntry | null;
|
workspace: SidebarWorkspaceEntry | null;
|
||||||
|
projectKey: string;
|
||||||
chevron?: "expand" | "collapse" | null;
|
chevron?: "expand" | "collapse" | null;
|
||||||
showChevron?: boolean;
|
showChevron?: boolean;
|
||||||
isArchiving?: boolean;
|
isArchiving?: boolean;
|
||||||
@@ -502,7 +497,11 @@ function ProjectLeadingVisual({
|
|||||||
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
|
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectLeadingVisualSlot}>
|
<View style={styles.projectLeadingVisualSlot}>
|
||||||
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
|
<ProjectIcon
|
||||||
|
iconDataUri={iconDataUri}
|
||||||
|
placeholderInitial={placeholderInitial}
|
||||||
|
projectKey={projectKey}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -511,6 +510,7 @@ function ProjectLeadingVisual({
|
|||||||
<ProjectLeadingVisualStatus
|
<ProjectLeadingVisualStatus
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
placeholderInitial={placeholderInitial}
|
placeholderInitial={placeholderInitial}
|
||||||
|
projectKey={projectKey}
|
||||||
isArchiving={isArchiving}
|
isArchiving={isArchiving}
|
||||||
shouldShowSyncedLoader={shouldShowSyncedLoader}
|
shouldShowSyncedLoader={shouldShowSyncedLoader}
|
||||||
activeWorkspace={activeWorkspace}
|
activeWorkspace={activeWorkspace}
|
||||||
@@ -789,30 +789,35 @@ function WorkspaceKebabMenu({
|
|||||||
function ProjectIcon({
|
function ProjectIcon({
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
placeholderInitial,
|
placeholderInitial,
|
||||||
|
projectKey,
|
||||||
}: {
|
}: {
|
||||||
iconDataUri: string | null;
|
iconDataUri: string | null;
|
||||||
placeholderInitial: string;
|
placeholderInitial: string;
|
||||||
|
projectKey: string;
|
||||||
}) {
|
}) {
|
||||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
|
||||||
if (iconDataUri) {
|
|
||||||
return <Image source={imageSource} style={styles.projectIcon} />;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectIconFallback}>
|
<ProjectIconView
|
||||||
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
iconDataUri={iconDataUri}
|
||||||
</View>
|
initial={placeholderInitial}
|
||||||
|
projectKey={projectKey}
|
||||||
|
imageStyle={styles.projectIcon}
|
||||||
|
fallbackStyle={styles.projectIconFallback}
|
||||||
|
textStyle={styles.projectIconFallbackText}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectLeadingVisualStatus({
|
function ProjectLeadingVisualStatus({
|
||||||
iconDataUri,
|
iconDataUri,
|
||||||
placeholderInitial,
|
placeholderInitial,
|
||||||
|
projectKey,
|
||||||
isArchiving,
|
isArchiving,
|
||||||
shouldShowSyncedLoader,
|
shouldShowSyncedLoader,
|
||||||
activeWorkspace,
|
activeWorkspace,
|
||||||
}: {
|
}: {
|
||||||
iconDataUri: string | null;
|
iconDataUri: string | null;
|
||||||
placeholderInitial: string;
|
placeholderInitial: string;
|
||||||
|
projectKey: string;
|
||||||
isArchiving: boolean;
|
isArchiving: boolean;
|
||||||
shouldShowSyncedLoader: boolean;
|
shouldShowSyncedLoader: boolean;
|
||||||
activeWorkspace: SidebarWorkspaceEntry;
|
activeWorkspace: SidebarWorkspaceEntry;
|
||||||
@@ -852,7 +857,11 @@ function ProjectLeadingVisualStatus({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.projectLeadingVisualSlot}>
|
<View style={styles.projectLeadingVisualSlot}>
|
||||||
<ProjectIcon iconDataUri={iconDataUri} placeholderInitial={placeholderInitial} />
|
<ProjectIcon
|
||||||
|
iconDataUri={iconDataUri}
|
||||||
|
placeholderInitial={placeholderInitial}
|
||||||
|
projectKey={projectKey}
|
||||||
|
/>
|
||||||
{dotColorStyle ? (
|
{dotColorStyle ? (
|
||||||
<StatusDotOverlay
|
<StatusDotOverlay
|
||||||
dotColorStyle={dotColorStyle}
|
dotColorStyle={dotColorStyle}
|
||||||
@@ -1244,6 +1253,7 @@ function ProjectHeaderRow({
|
|||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
|
projectKey={project.projectKey}
|
||||||
chevron={chevron}
|
chevron={chevron}
|
||||||
showChevron={isHovered && chevron !== null}
|
showChevron={isHovered && chevron !== null}
|
||||||
isArchiving={isArchiving}
|
isArchiving={isArchiving}
|
||||||
@@ -2350,7 +2360,7 @@ export function SidebarWorkspaceList({
|
|||||||
const result = await client.requestProjectIcon(request.cwd);
|
const result = await client.requestProjectIcon(request.cwd);
|
||||||
return result.icon;
|
return result.icon;
|
||||||
},
|
},
|
||||||
select: toProjectIconDataUri,
|
select: projectIconToDataUri,
|
||||||
enabled: Boolean(
|
enabled: Boolean(
|
||||||
getHostRuntimeStore().getClient(request.serverId) &&
|
getHostRuntimeStore().getClient(request.serverId) &&
|
||||||
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
|
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) &&
|
||||||
@@ -2713,13 +2723,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
borderRadius: theme.borderRadius.sm,
|
borderRadius: theme.borderRadius.sm,
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
projectIconFallbackText: {
|
projectIconFallbackText: {
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
},
|
},
|
||||||
projectTitle: {
|
projectTitle: {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export function projectIconQueryKey(serverId: string, cwd: string) {
|
|||||||
return ["projectIcon", serverId, cwd] as const;
|
return ["projectIcon", serverId, cwd] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function projectIconToDataUri(icon: ProjectIcon | null): string | null {
|
||||||
|
if (!icon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return `data:${icon.mimeType};base64,${icon.data}`;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseProjectIconQueryOptions {
|
interface UseProjectIconQueryOptions {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Image, Pressable, Text, TextInput, View } from "react-native";
|
import { Pressable, Text, TextInput, View } from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
|
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
|
||||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
import { ProjectIconView } from "@/components/project-icon-view";
|
||||||
|
import { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||||
import type {
|
import type {
|
||||||
PaseoConfigRaw,
|
PaseoConfigRaw,
|
||||||
PaseoConfigRevision,
|
PaseoConfigRevision,
|
||||||
@@ -234,7 +235,11 @@ function ProjectSettingsBody({
|
|||||||
|
|
||||||
<View style={styles.headerBlock}>
|
<View style={styles.headerBlock}>
|
||||||
<View style={styles.titleRow}>
|
<View style={styles.titleRow}>
|
||||||
<ProjectTitleIcon host={selectedHost} projectName={project.projectName} />
|
<ProjectTitleIcon
|
||||||
|
host={selectedHost}
|
||||||
|
projectName={project.projectName}
|
||||||
|
projectKey={project.projectKey}
|
||||||
|
/>
|
||||||
<ProjectNameEditor project={project} client={client} />
|
<ProjectNameEditor project={project} client={client} />
|
||||||
</View>
|
</View>
|
||||||
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
|
<HostContext hosts={hosts} selectedHost={selectedHost} onSelectHost={onSelectHost} />
|
||||||
@@ -880,19 +885,26 @@ function ProjectNameEditor({ project, client }: ProjectNameEditorProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectTitleIcon({ host, projectName }: { host: ProjectHostEntry; projectName: string }) {
|
function ProjectTitleIcon({
|
||||||
|
host,
|
||||||
|
projectName,
|
||||||
|
projectKey,
|
||||||
|
}: {
|
||||||
|
host: ProjectHostEntry;
|
||||||
|
projectName: string;
|
||||||
|
projectKey: string;
|
||||||
|
}) {
|
||||||
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
|
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
|
||||||
const { icon } = useProjectIconQuery({ serverId: host.serverId, cwd: host.repoRoot });
|
const { icon } = useProjectIconQuery({ serverId: host.serverId, cwd: host.repoRoot });
|
||||||
const iconDataUri =
|
|
||||||
icon && icon.data && icon.mimeType ? `data:${icon.mimeType};base64,${icon.data}` : null;
|
|
||||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
|
||||||
if (iconDataUri) {
|
|
||||||
return <Image source={imageSource} style={styles.titleIcon} />;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.titleIconFallback}>
|
<ProjectIconView
|
||||||
<Text style={styles.titleIconFallbackText}>{initial}</Text>
|
iconDataUri={projectIconToDataUri(icon)}
|
||||||
</View>
|
initial={initial}
|
||||||
|
projectKey={projectKey}
|
||||||
|
imageStyle={styles.titleIcon}
|
||||||
|
fallbackStyle={styles.titleIconFallback}
|
||||||
|
textStyle={styles.titleIconFallbackText}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1288,12 +1300,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
titleIconFallbackText: {
|
titleIconFallbackText: {
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback } from "react";
|
||||||
import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronRight } from "lucide-react-native";
|
import { ChevronRight } from "lucide-react-native";
|
||||||
|
import { ProjectIconView } from "@/components/project-icon-view";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
import { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||||
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||||
@@ -101,7 +102,7 @@ function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
|
|||||||
>
|
>
|
||||||
<View style={styles.rowMain}>
|
<View style={styles.rowMain}>
|
||||||
<View style={styles.leading}>
|
<View style={styles.leading}>
|
||||||
<ProjectRowIcon host={leadingHost} projectName={projectName} />
|
<ProjectRowIcon host={leadingHost} projectName={projectName} projectKey={projectKey} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||||
{projectName}
|
{projectName}
|
||||||
@@ -115,26 +116,26 @@ function ProjectRow({ project, isFirst, isSelected }: ProjectRowProps) {
|
|||||||
function ProjectRowIcon({
|
function ProjectRowIcon({
|
||||||
host,
|
host,
|
||||||
projectName,
|
projectName,
|
||||||
|
projectKey,
|
||||||
}: {
|
}: {
|
||||||
host: ProjectHostEntry | undefined;
|
host: ProjectHostEntry | undefined;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
projectKey: string;
|
||||||
}) {
|
}) {
|
||||||
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
|
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
|
||||||
const { icon } = useProjectIconQuery({
|
const { icon } = useProjectIconQuery({
|
||||||
serverId: host?.serverId ?? "",
|
serverId: host?.serverId ?? "",
|
||||||
cwd: host?.repoRoot ?? "",
|
cwd: host?.repoRoot ?? "",
|
||||||
});
|
});
|
||||||
const iconDataUri =
|
|
||||||
icon && icon.data && icon.mimeType ? `data:${icon.mimeType};base64,${icon.data}` : null;
|
|
||||||
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
|
|
||||||
|
|
||||||
if (iconDataUri) {
|
|
||||||
return <Image source={imageSource} style={styles.iconImage} />;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.iconFallback}>
|
<ProjectIconView
|
||||||
<Text style={styles.iconFallbackText}>{initial}</Text>
|
iconDataUri={projectIconToDataUri(icon)}
|
||||||
</View>
|
initial={initial}
|
||||||
|
projectKey={projectKey}
|
||||||
|
imageStyle={styles.iconImage}
|
||||||
|
fallbackStyle={styles.iconFallback}
|
||||||
|
textStyle={styles.iconFallbackText}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,12 +196,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
borderRadius: theme.borderRadius.sm,
|
borderRadius: theme.borderRadius.sm,
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
iconFallbackText: {
|
iconFallbackText: {
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
},
|
},
|
||||||
spinnerColor: {
|
spinnerColor: {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Medium-saturation tones chosen so a white letter stays legible on top in both
|
||||||
|
// light and dark themes. Soft and colorful, but not pale pastels.
|
||||||
|
const PROJECT_ICON_COLORS = [
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#0ea5e9", // sky
|
||||||
|
"#10b981", // emerald
|
||||||
|
"#f97316", // orange
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#6366f1", // indigo
|
||||||
|
"#14b8a6", // teal
|
||||||
|
"#ef4444", // red
|
||||||
|
"#eab308", // amber
|
||||||
|
"#3b82f6", // blue
|
||||||
|
];
|
||||||
|
|
||||||
|
function hashProjectKey(projectKey: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (const character of projectKey) {
|
||||||
|
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveProjectIconColor(projectKey: string): string {
|
||||||
|
return PROJECT_ICON_COLORS[hashProjectKey(projectKey) % PROJECT_ICON_COLORS.length];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user