Three card families: Collectibles (mint) + Agents (skill-bar back); New-card under cards
- localCardsStore: cards gain `kind` (business/collectible/agent) + optional provenance (collectibles) and agent meta (agents); cardsOfKind() filter. - Cards tab: "+ New card" moved to a footer below the cards (no floating FAB); filtered to business cards. - Collectibles + Agents tabs: real grids (CardCollection) with empty states and a "+ New" footer that opens the creator pre-set to that kind. - Creator (demo.tsx) is kind-aware: collectibles mint a provenance record on save (utils/provenance stub, mapped to the creator); agents capture flip-side details (AgentDetailsEditor: tagline, skill bars, tools, capabilities). - Card back per kind: AgentBackTemplate (skill bars/tools/capabilities) for agents; CardBackTemplate gains a "Minted · #token · chain" footer for collectibles. Both reuse the AI image/video front workflow. - tsc + 28 jest green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e77e812609
commit
d30deaf857
@@ -1,27 +1,15 @@
|
|||||||
// Work-agent cards — AI agents attached to your cards. None yet; this is their
|
// Agents: cards for your AI assistants. Front is an AI-generated portrait; the
|
||||||
// home.
|
// flip side shows skill bars, tools, and special capabilities.
|
||||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
import { CardCollection } from "../../src/components/CardCollection";
|
||||||
import { StyleSheet, Text, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function Agents() {
|
export default function Agents() {
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.root, { paddingTop: insets.top }]}>
|
<CardCollection
|
||||||
<View style={styles.center}>
|
kind="agent"
|
||||||
<MaterialCommunityIcons name="robot" size={48} color="#3a3a44" />
|
heading="Agents"
|
||||||
<Text style={styles.title}>No agent cards yet</Text>
|
addLabel="+ New agent"
|
||||||
<Text style={styles.hint}>
|
emptyTitle="No agent cards yet"
|
||||||
Cards backed by an AI work agent will appear here.
|
emptyHint="Generate a portrait of your assistant; flip it for skills & tools."
|
||||||
</Text>
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
root: { flex: 1, backgroundColor: "#0a0a0c" },
|
|
||||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 32, gap: 10 },
|
|
||||||
title: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
|
|
||||||
hint: { color: "#6b6b70", fontSize: 14, textAlign: "center", lineHeight: 20 },
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
// Collectible cards — cards you've collected from people you meet (a deck you
|
// Collectibles: AI-generated cards minted to the blockchain (provenance mapped
|
||||||
// build up). No collectibles yet; this is the home for them.
|
// to the creator). Create flow reuses the card creator with kind=collectible.
|
||||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
import { CardCollection } from "../../src/components/CardCollection";
|
||||||
import { StyleSheet, Text, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function Collectibles() {
|
export default function Collectibles() {
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.root, { paddingTop: insets.top }]}>
|
<CardCollection
|
||||||
<View style={styles.center}>
|
kind="collectible"
|
||||||
<MaterialCommunityIcons name="sack" size={48} color="#3a3a44" />
|
heading="Collectibles"
|
||||||
<Text style={styles.title}>No collectibles yet</Text>
|
addLabel="+ New collectible"
|
||||||
<Text style={styles.hint}>
|
emptyTitle="No collectibles yet"
|
||||||
Cards you collect from people you meet will live here — build your deck.
|
emptyHint="Create one with AI — it's minted to the chain and mapped to you."
|
||||||
</Text>
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
root: { flex: 1, backgroundColor: "#0a0a0c" },
|
|
||||||
center: { flex: 1, alignItems: "center", justifyContent: "center", padding: 32, gap: 10 },
|
|
||||||
title: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
|
|
||||||
hint: { color: "#6b6b70", fontSize: 14, textAlign: "center", lineHeight: 20 },
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
|||||||
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
|
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { CardThumb } from "../../src/components/CardThumb";
|
import { CardThumb } from "../../src/components/CardThumb";
|
||||||
import { LocalCard, useLocalCardsStore } from "../../src/stores/localCardsStore";
|
import { cardsOfKind, LocalCard, useLocalCardsStore } from "../../src/stores/localCardsStore";
|
||||||
|
|
||||||
type ViewMode = "grid" | "showcase";
|
type ViewMode = "grid" | "showcase";
|
||||||
|
|
||||||
@@ -20,7 +20,10 @@ function columnsFor(count: number): number {
|
|||||||
export default function Gallery() {
|
export default function Gallery() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const cards = useLocalCardsStore((s) => s.cards);
|
const cards = cardsOfKind(
|
||||||
|
useLocalCardsStore((s) => s.cards),
|
||||||
|
"business",
|
||||||
|
);
|
||||||
const [view, setView] = useState<ViewMode>("grid");
|
const [view, setView] = useState<ViewMode>("grid");
|
||||||
const columns = view === "showcase" ? 1 : columnsFor(cards.length);
|
const columns = view === "showcase" ? 1 : columnsFor(cards.length);
|
||||||
|
|
||||||
@@ -44,11 +47,9 @@ export default function Gallery() {
|
|||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
view === "showcase" && cards.length > 0 ? (
|
<Pressable style={styles.addTile} onPress={() => router.push("/demo")}>
|
||||||
<Pressable style={styles.addTile} onPress={() => router.push("/demo")}>
|
<Text style={styles.addTileText}>+ New card</Text>
|
||||||
<Text style={styles.addTileText}>+ New card</Text>
|
</Pressable>
|
||||||
</Pressable>
|
|
||||||
) : null
|
|
||||||
}
|
}
|
||||||
renderItem={({ item }: { item: LocalCard }) =>
|
renderItem={({ item }: { item: LocalCard }) =>
|
||||||
view === "showcase" ? (
|
view === "showcase" ? (
|
||||||
@@ -83,9 +84,6 @@ export default function Gallery() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Pressable style={[styles.fab, { bottom: insets.bottom + 80 }]} onPress={() => router.push("/demo")}>
|
|
||||||
<Text style={styles.fabText}>+ New card</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,22 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { AgentDetailsEditor } from "../src/components/AgentDetailsEditor";
|
||||||
|
import { AgentBackTemplate } from "../src/components/card/AgentBackTemplate";
|
||||||
import { CardBackTemplate } from "../src/components/card/CardBackTemplate";
|
import { CardBackTemplate } from "../src/components/card/CardBackTemplate";
|
||||||
import { CardViewer } from "../src/components/card/CardViewer";
|
import { CardViewer } from "../src/components/card/CardViewer";
|
||||||
import { defaultLinks, LinksEditor } from "../src/components/LinksEditor";
|
import { defaultLinks } from "../src/components/LinksEditor";
|
||||||
import { newLayerId } from "../src/stores/cardStore";
|
import { newLayerId } from "../src/stores/cardStore";
|
||||||
import { useDraftStore } from "../src/stores/draftStore";
|
import { useDraftStore } from "../src/stores/draftStore";
|
||||||
import { CardLink, useLocalCardsStore } from "../src/stores/localCardsStore";
|
import {
|
||||||
|
AgentMeta,
|
||||||
|
CardKind,
|
||||||
|
CardLink,
|
||||||
|
Provenance,
|
||||||
|
useLocalCardsStore,
|
||||||
|
} from "../src/stores/localCardsStore";
|
||||||
import { useProfileStore } from "../src/stores/profileStore";
|
import { useProfileStore } from "../src/stores/profileStore";
|
||||||
|
import { mintProvenance } from "../src/utils/provenance";
|
||||||
import { CardDefinition, DEFAULT_SETTINGS, TextLayer } from "../src/types/card";
|
import { CardDefinition, DEFAULT_SETTINGS, TextLayer } from "../src/types/card";
|
||||||
import { deleteImage, persistImage, persistVideo } from "../src/utils/imageStore";
|
import { deleteImage, persistImage, persistVideo } from "../src/utils/imageStore";
|
||||||
|
|
||||||
@@ -100,7 +109,7 @@ function buildDemoCard(
|
|||||||
|
|
||||||
export default function CardEditorScreen() {
|
export default function CardEditorScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { cardId } = useLocalSearchParams<{ cardId?: string }>();
|
const { cardId, kind: kindParam } = useLocalSearchParams<{ cardId?: string; kind?: string }>();
|
||||||
const upsert = useLocalCardsStore((s) => s.upsert);
|
const upsert = useLocalCardsStore((s) => s.upsert);
|
||||||
const remove = useLocalCardsStore((s) => s.remove);
|
const remove = useLocalCardsStore((s) => s.remove);
|
||||||
const getById = useLocalCardsStore((s) => s.getById);
|
const getById = useLocalCardsStore((s) => s.getById);
|
||||||
@@ -118,6 +127,14 @@ export default function CardEditorScreen() {
|
|||||||
const [links, setLinks] = useState<CardLink[]>(() =>
|
const [links, setLinks] = useState<CardLink[]>(() =>
|
||||||
cardId ? [] : profile.socialLinks.length > 0 ? profile.socialLinks : defaultLinks(),
|
cardId ? [] : profile.socialLinks.length > 0 ? profile.socialLinks : defaultLinks(),
|
||||||
);
|
);
|
||||||
|
const [cardKind, setCardKind] = useState<CardKind>(() => (kindParam as CardKind) || "business");
|
||||||
|
const [agentMeta, setAgentMeta] = useState<AgentMeta>(() => ({
|
||||||
|
tagline: "",
|
||||||
|
skills: [],
|
||||||
|
tools: [],
|
||||||
|
capabilities: [],
|
||||||
|
}));
|
||||||
|
const [provenance, setProvenance] = useState<Provenance | undefined>(undefined);
|
||||||
const [showing, setShowing] = useState(false);
|
const [showing, setShowing] = useState(false);
|
||||||
const [onBack, setOnBack] = useState(false);
|
const [onBack, setOnBack] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -133,6 +150,9 @@ export default function CardEditorScreen() {
|
|||||||
setTitle(existing.title);
|
setTitle(existing.title);
|
||||||
setUrl(existing.url);
|
setUrl(existing.url);
|
||||||
setLinks(existing.links ?? []);
|
setLinks(existing.links ?? []);
|
||||||
|
setCardKind(existing.kind ?? "business");
|
||||||
|
setProvenance(existing.provenance);
|
||||||
|
if (existing.agent) setAgentMeta(existing.agent);
|
||||||
setOnBack(false);
|
setOnBack(false);
|
||||||
setShowing(true);
|
setShowing(true);
|
||||||
}
|
}
|
||||||
@@ -189,17 +209,28 @@ export default function CardEditorScreen() {
|
|||||||
const existing = getById(id);
|
const existing = getById(id);
|
||||||
const imagePath = imageUri ? await persistImage(imageUri, id) : (existing?.imagePath ?? "");
|
const imagePath = imageUri ? await persistImage(imageUri, id) : (existing?.imagePath ?? "");
|
||||||
const videoPath = videoUri ? await persistVideo(videoUri, id) : undefined;
|
const videoPath = videoUri ? await persistVideo(videoUri, id) : undefined;
|
||||||
|
// Collectibles are "minted" (provenance recorded, mapped to the creator) on
|
||||||
|
// acceptance — once; agents carry their flip-side metadata.
|
||||||
|
const prov =
|
||||||
|
cardKind === "collectible"
|
||||||
|
? (existing?.provenance ?? mintProvenance(profile.displayName || name))
|
||||||
|
: undefined;
|
||||||
|
const agent = cardKind === "agent" ? agentMeta : undefined;
|
||||||
upsert({
|
upsert({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
title,
|
title,
|
||||||
url,
|
url,
|
||||||
|
kind: cardKind,
|
||||||
imagePath,
|
imagePath,
|
||||||
videoPath,
|
videoPath,
|
||||||
links,
|
links,
|
||||||
publishedUrl: existing?.publishedUrl,
|
publishedUrl: existing?.publishedUrl,
|
||||||
|
provenance: prov,
|
||||||
|
agent,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
setProvenance(prov);
|
||||||
if (imageUri) setImageUri(imagePath);
|
if (imageUri) setImageUri(imagePath);
|
||||||
if (videoPath) setVideoUri(videoPath);
|
if (videoPath) setVideoUri(videoPath);
|
||||||
};
|
};
|
||||||
@@ -247,7 +278,17 @@ export default function CardEditorScreen() {
|
|||||||
fullScreen
|
fullScreen
|
||||||
onSideChange={setOnBack}
|
onSideChange={setOnBack}
|
||||||
backContent={
|
backContent={
|
||||||
<CardBackTemplate name={name} title={title} profileUrl={url} links={links} />
|
cardKind === "agent" ? (
|
||||||
|
<AgentBackTemplate name={name} title={title} agent={agentMeta} />
|
||||||
|
) : (
|
||||||
|
<CardBackTemplate
|
||||||
|
name={name}
|
||||||
|
title={title}
|
||||||
|
profileUrl={url}
|
||||||
|
links={links}
|
||||||
|
provenance={cardKind === "collectible" ? provenance : undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Front: a hint to flip. Back (QR side): the Gallery / Edit controls. */}
|
{/* Front: a hint to flip. Back (QR side): the Gallery / Edit controls. */}
|
||||||
@@ -270,7 +311,16 @@ export default function CardEditorScreen() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
|
<ScrollView style={styles.root} contentContainerStyle={styles.content}>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
options={{ title: cardId ? "Edit card" : "Make your card", headerTitleAlign: "center" }}
|
options={{
|
||||||
|
title: cardId
|
||||||
|
? "Edit card"
|
||||||
|
: cardKind === "collectible"
|
||||||
|
? "New collectible"
|
||||||
|
: cardKind === "agent"
|
||||||
|
? "New agent"
|
||||||
|
: "Make your card",
|
||||||
|
headerTitleAlign: "center",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{videoUri ? (
|
{videoUri ? (
|
||||||
@@ -310,6 +360,13 @@ export default function CardEditorScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{cardKind === "agent" && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.sectionLabel}>Agent details · shown on the flip side</Text>
|
||||||
|
<AgentDetailsEditor meta={agentMeta} onChange={setAgentMeta} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.cta, (!media || saving) && styles.ctaDisabled]}
|
style={[styles.cta, (!media || saving) && styles.ctaDisabled]}
|
||||||
disabled={!media || saving}
|
disabled={!media || saving}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Editor for an agent card's flip-side data: tagline, skill bars, tools, and
|
||||||
|
// special capabilities.
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
|
||||||
|
import { AgentMeta } from "../stores/localCardsStore";
|
||||||
|
|
||||||
|
const splitCsv = (s: string) =>
|
||||||
|
s
|
||||||
|
.split(",")
|
||||||
|
.map((x) => x.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
export function AgentDetailsEditor({
|
||||||
|
meta,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
meta: AgentMeta;
|
||||||
|
onChange: (m: AgentMeta) => void;
|
||||||
|
}) {
|
||||||
|
// Keep the comma-separated fields as raw text so separators are typeable.
|
||||||
|
const [toolsText, setToolsText] = useState(meta.tools.join(", "));
|
||||||
|
const [capsText, setCapsText] = useState(meta.capabilities.join(", "));
|
||||||
|
const set = (patch: Partial<AgentMeta>) => onChange({ ...meta, ...patch });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ gap: 10 }}>
|
||||||
|
<Text style={styles.label}>Tagline</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="e.g. Your always-on research copilot"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
value={meta.tagline}
|
||||||
|
onChangeText={(t) => set({ tagline: t })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Skills</Text>
|
||||||
|
{meta.skills.map((s, i) => (
|
||||||
|
<View key={i} style={styles.skillRow}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { flex: 1 }]}
|
||||||
|
placeholder="Skill"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
value={s.name}
|
||||||
|
onChangeText={(t) =>
|
||||||
|
set({ skills: meta.skills.map((x, idx) => (idx === i ? { ...x, name: t } : x)) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<View style={styles.dots}>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<Pressable
|
||||||
|
key={n}
|
||||||
|
onPress={() =>
|
||||||
|
set({ skills: meta.skills.map((x, idx) => (idx === i ? { ...x, level: n } : x)) })
|
||||||
|
}
|
||||||
|
style={[styles.dot, n <= s.level && styles.dotOn]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Pressable onPress={() => set({ skills: meta.skills.filter((_, idx) => idx !== i) })} hitSlop={8}>
|
||||||
|
<Text style={styles.remove}>✕</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<Pressable
|
||||||
|
style={styles.add}
|
||||||
|
onPress={() => set({ skills: [...meta.skills, { name: "", level: 3 }] })}
|
||||||
|
>
|
||||||
|
<Text style={styles.addText}>+ Add skill</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Tools (comma-separated)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="e.g. Search, Code, Email"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
autoCapitalize="none"
|
||||||
|
value={toolsText}
|
||||||
|
onChangeText={(t) => {
|
||||||
|
setToolsText(t);
|
||||||
|
set({ tools: splitCsv(t) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text style={styles.label}>Special capabilities (comma-separated)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, { minHeight: 64, textAlignVertical: "top" }]}
|
||||||
|
multiline
|
||||||
|
placeholder="e.g. Multi-step planning, Long-term memory"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
value={capsText}
|
||||||
|
onChangeText={(t) => {
|
||||||
|
setCapsText(t);
|
||||||
|
set({ capabilities: splitCsv(t) });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
label: { color: "#9a9aa0", fontSize: 14, marginTop: 4 },
|
||||||
|
input: {
|
||||||
|
backgroundColor: "#15151a",
|
||||||
|
color: "#f5f5f7",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
skillRow: { flexDirection: "row", alignItems: "center", gap: 10 },
|
||||||
|
dots: { flexDirection: "row", gap: 4 },
|
||||||
|
dot: { width: 14, height: 14, borderRadius: 7, backgroundColor: "#26262e" },
|
||||||
|
dotOn: { backgroundColor: "#ff3b30" },
|
||||||
|
remove: { color: "#ff453a", fontSize: 16, fontWeight: "700", paddingHorizontal: 4 },
|
||||||
|
add: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#2a2a30",
|
||||||
|
borderStyle: "dashed",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
addText: { color: "#f5f5f7", fontWeight: "600", fontSize: 15 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Reusable grid for a card family (Collectibles / Agents): responsive columns,
|
||||||
|
// an empty state, and a "+ New" tile below the cards that starts the creator
|
||||||
|
// pre-set to that kind.
|
||||||
|
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { CardKind, cardsOfKind, LocalCard, useLocalCardsStore } from "../stores/localCardsStore";
|
||||||
|
import { CardThumb } from "./CardThumb";
|
||||||
|
|
||||||
|
function columnsFor(count: number): number {
|
||||||
|
if (count <= 4) return 2;
|
||||||
|
if (count <= 9) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardCollection({
|
||||||
|
kind,
|
||||||
|
heading,
|
||||||
|
addLabel,
|
||||||
|
emptyTitle,
|
||||||
|
emptyHint,
|
||||||
|
}: {
|
||||||
|
kind: CardKind;
|
||||||
|
heading: string;
|
||||||
|
addLabel: string;
|
||||||
|
emptyTitle: string;
|
||||||
|
emptyHint: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const cards = cardsOfKind(
|
||||||
|
useLocalCardsStore((s) => s.cards),
|
||||||
|
kind,
|
||||||
|
);
|
||||||
|
const columns = columnsFor(cards.length);
|
||||||
|
const openNew = () => router.push({ pathname: "/demo", params: { kind } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<View style={[styles.topBar, { paddingTop: insets.top + 14 }]}>
|
||||||
|
<Text style={styles.heading}>{heading}</Text>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={cards}
|
||||||
|
keyExtractor={(c) => c.id}
|
||||||
|
key={`cols-${columns}`}
|
||||||
|
numColumns={columns}
|
||||||
|
columnWrapperStyle={columns > 1 ? styles.column : undefined}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View style={styles.empty}>
|
||||||
|
<Text style={styles.emptyTitle}>{emptyTitle}</Text>
|
||||||
|
<Text style={styles.emptyHint}>{emptyHint}</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
<Pressable style={styles.addTile} onPress={openNew}>
|
||||||
|
<Text style={styles.addText}>{addLabel}</Text>
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
renderItem={({ item }: { item: LocalCard }) => (
|
||||||
|
<Pressable
|
||||||
|
style={styles.tile}
|
||||||
|
onPress={() => router.push({ pathname: "/demo", params: { cardId: item.id } })}
|
||||||
|
>
|
||||||
|
<CardThumb item={item} style={styles.tileImage} />
|
||||||
|
<View style={styles.tileLabel}>
|
||||||
|
<Text style={styles.tileName} numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
{!!item.title && (
|
||||||
|
<Text style={styles.tileTitle} numberOfLines={1}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: "#0a0a0c" },
|
||||||
|
topBar: { paddingHorizontal: 16, paddingBottom: 10 },
|
||||||
|
heading: { color: "#f5f5f7", fontSize: 26, fontWeight: "800" },
|
||||||
|
list: { paddingHorizontal: 16, paddingBottom: 24, flexGrow: 1 },
|
||||||
|
column: { gap: 12 },
|
||||||
|
tile: { flex: 1, marginBottom: 12, borderRadius: 18, overflow: "hidden", backgroundColor: "#15151a" },
|
||||||
|
tileImage: { width: "100%", aspectRatio: 2 / 3, overflow: "hidden" },
|
||||||
|
tileLabel: { padding: 10 },
|
||||||
|
tileName: { color: "#f5f5f7", fontSize: 14, fontWeight: "700" },
|
||||||
|
tileTitle: { color: "#9a9aa0", fontSize: 12, marginTop: 2 },
|
||||||
|
addTile: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#2a2a30",
|
||||||
|
borderStyle: "dashed",
|
||||||
|
borderRadius: 18,
|
||||||
|
paddingVertical: 22,
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
addText: { color: "#f5f5f7", fontWeight: "700", fontSize: 16 },
|
||||||
|
empty: { alignItems: "center", justifyContent: "center", paddingVertical: 80, gap: 6 },
|
||||||
|
emptyTitle: { color: "#f5f5f7", fontSize: 20, fontWeight: "700" },
|
||||||
|
emptyHint: { color: "#6b6b70", fontSize: 14, textAlign: "center", paddingHorizontal: 24 },
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Agent card back: shown when an agent card is flipped. Renders the assistant's
|
||||||
|
// skill bars, tools, and special capabilities (trading-card style).
|
||||||
|
|
||||||
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
|
import { AgentMeta } from "../../stores/localCardsStore";
|
||||||
|
|
||||||
|
export function AgentBackTemplate({
|
||||||
|
name,
|
||||||
|
title,
|
||||||
|
agent,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
agent: AgentMeta;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.root}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
{!!title && (
|
||||||
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{!!agent.tagline && <Text style={styles.tagline}>{agent.tagline}</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
|
||||||
|
{agent.skills.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.section}>Skills</Text>
|
||||||
|
{agent.skills.map((s, i) => (
|
||||||
|
<View key={`${s.name}-${i}`} style={styles.skillRow}>
|
||||||
|
<Text style={styles.skillName} numberOfLines={1}>
|
||||||
|
{s.name}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.bars}>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<View key={n} style={[styles.bar, n <= s.level && styles.barOn]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agent.tools.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.section}>Tools</Text>
|
||||||
|
<View style={styles.chips}>
|
||||||
|
{agent.tools.map((t, i) => (
|
||||||
|
<View key={`${t}-${i}`} style={styles.chip}>
|
||||||
|
<MaterialCommunityIcons name="wrench" size={13} color="#cbb8ff" />
|
||||||
|
<Text style={styles.chipText}>{t}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agent.capabilities.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text style={styles.section}>Special capabilities</Text>
|
||||||
|
{agent.capabilities.map((c, i) => (
|
||||||
|
<View key={`${c}-${i}`} style={styles.capRow}>
|
||||||
|
<MaterialCommunityIcons name="star-four-points" size={14} color="#ffd60a" />
|
||||||
|
<Text style={styles.capText}>{c}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
root: { flex: 1, backgroundColor: "#0e0e12", paddingHorizontal: 22, paddingVertical: 28 },
|
||||||
|
header: { marginBottom: 12 },
|
||||||
|
name: { color: "#ffffff", fontSize: 24, fontWeight: "800" },
|
||||||
|
title: { color: "#9a9aa0", fontSize: 15, marginTop: 2 },
|
||||||
|
tagline: { color: "#cbb8ff", fontSize: 13, marginTop: 6, fontStyle: "italic" },
|
||||||
|
body: { gap: 8, paddingBottom: 16 },
|
||||||
|
section: {
|
||||||
|
color: "#9a9aa0",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "700",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.06 * 12,
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
skillRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12 },
|
||||||
|
skillName: { color: "#f5f5f7", fontSize: 15, flex: 1 },
|
||||||
|
bars: { flexDirection: "row", gap: 4 },
|
||||||
|
bar: { width: 16, height: 8, borderRadius: 2, backgroundColor: "#26262e" },
|
||||||
|
barOn: { backgroundColor: "#ff3b30" },
|
||||||
|
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
||||||
|
chip: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
backgroundColor: "#16161c",
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 7,
|
||||||
|
},
|
||||||
|
chipText: { color: "#f5f5f7", fontSize: 13, fontWeight: "600" },
|
||||||
|
capRow: { flexDirection: "row", alignItems: "center", gap: 8 },
|
||||||
|
capText: { color: "#e0e0e4", fontSize: 14, flex: 1 },
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
||||||
import { CardLink, LinkKind } from "../../stores/localCardsStore";
|
import { CardLink, LinkKind, Provenance } from "../../stores/localCardsStore";
|
||||||
import { QRCodeView } from "./QRCodeView";
|
import { QRCodeView } from "./QRCodeView";
|
||||||
|
|
||||||
const KIND_ICON: Record<LinkKind, keyof typeof MaterialCommunityIcons.glyphMap> = {
|
const KIND_ICON: Record<LinkKind, keyof typeof MaterialCommunityIcons.glyphMap> = {
|
||||||
@@ -24,11 +24,13 @@ export function CardBackTemplate({
|
|||||||
title,
|
title,
|
||||||
profileUrl,
|
profileUrl,
|
||||||
links,
|
links,
|
||||||
|
provenance,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
profileUrl: string;
|
profileUrl: string;
|
||||||
links: CardLink[];
|
links: CardLink[];
|
||||||
|
provenance?: Provenance;
|
||||||
}) {
|
}) {
|
||||||
// Only show links that actually have a URL (empty ones would be blank QRs).
|
// Only show links that actually have a URL (empty ones would be blank QRs).
|
||||||
const shown = links.filter((l) => l.url.trim());
|
const shown = links.filter((l) => l.url.trim());
|
||||||
@@ -81,6 +83,15 @@ export function CardBackTemplate({
|
|||||||
<Text style={styles.empty}>Add links in the editor to show scannable codes here.</Text>
|
<Text style={styles.empty}>Add links in the editor to show scannable codes here.</Text>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{provenance && (
|
||||||
|
<View style={styles.provenance}>
|
||||||
|
<MaterialCommunityIcons name="link-variant" size={13} color="#30d158" />
|
||||||
|
<Text style={styles.provText} numberOfLines={1}>
|
||||||
|
Minted · #{provenance.tokenId} · {provenance.chain}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -117,4 +128,14 @@ const styles = StyleSheet.create({
|
|||||||
rowLabel: { color: "#f5f5f7", fontSize: 15, fontWeight: "700" },
|
rowLabel: { color: "#f5f5f7", fontSize: 15, fontWeight: "700" },
|
||||||
rowUrl: { color: "#8a8a90", fontSize: 12, marginTop: 1 },
|
rowUrl: { color: "#8a8a90", fontSize: 12, marginTop: 1 },
|
||||||
empty: { color: "#6b6b70", fontSize: 13, textAlign: "center", paddingVertical: 24 },
|
empty: { color: "#6b6b70", fontSize: 13, textAlign: "center", paddingVertical: 24 },
|
||||||
|
provenance: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 10,
|
||||||
|
paddingTop: 10,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: "#2a2a30",
|
||||||
|
},
|
||||||
|
provText: { color: "#9a9aa0", fontSize: 12 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,11 +18,39 @@ export interface CardLink {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The three card families, one per tab. Missing = "business" (legacy cards). */
|
||||||
|
export type CardKind = "business" | "collectible" | "agent";
|
||||||
|
|
||||||
|
/** Blockchain provenance recorded when a collectible is minted (stubbed). */
|
||||||
|
export interface Provenance {
|
||||||
|
tokenId: string;
|
||||||
|
txHash: string;
|
||||||
|
chain: string;
|
||||||
|
owner: string;
|
||||||
|
mintedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSkill {
|
||||||
|
name: string;
|
||||||
|
/** 1–5 bars. */
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extra metadata shown on an agent card's flipped (back) side. */
|
||||||
|
export interface AgentMeta {
|
||||||
|
tagline: string;
|
||||||
|
skills: AgentSkill[];
|
||||||
|
tools: string[];
|
||||||
|
capabilities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalCard {
|
export interface LocalCard {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
/** Which tab/family this card belongs to (default "business"). */
|
||||||
|
kind?: CardKind;
|
||||||
/** Persistent file:// path to the card photo. */
|
/** Persistent file:// path to the card photo. */
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
/** Persistent file:// path to an AI video for the card front (optional). */
|
/** Persistent file:// path to an AI video for the card front (optional). */
|
||||||
@@ -31,9 +59,18 @@ export interface LocalCard {
|
|||||||
links: CardLink[];
|
links: CardLink[];
|
||||||
/** Public profile URL once published to the web (cardclaws.com/<handle>). */
|
/** Public profile URL once published to the web (cardclaws.com/<handle>). */
|
||||||
publishedUrl?: string;
|
publishedUrl?: string;
|
||||||
|
/** Collectibles: on-chain provenance, mapped to the creating user. */
|
||||||
|
provenance?: Provenance;
|
||||||
|
/** Agents: skills/tools/capabilities shown on the flip side. */
|
||||||
|
agent?: AgentMeta;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cards belonging to a family (treats missing kind as "business"). */
|
||||||
|
export function cardsOfKind(cards: LocalCard[], kind: CardKind): LocalCard[] {
|
||||||
|
return cards.filter((c) => (c.kind ?? "business") === kind);
|
||||||
|
}
|
||||||
|
|
||||||
interface LocalCardsState {
|
interface LocalCardsState {
|
||||||
cards: LocalCard[];
|
cards: LocalCard[];
|
||||||
upsert: (card: LocalCard) => void;
|
upsert: (card: LocalCard) => void;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Collectible "minting" — records on-chain provenance for a generated asset and
|
||||||
|
// maps it to the creating user. Stubbed for now (deterministic-looking fake tx);
|
||||||
|
// the real chain submission swaps in behind this same shape (PRD §21, plan
|
||||||
|
// Phase 2 ProvenanceClient).
|
||||||
|
|
||||||
|
import { Provenance } from "../stores/localCardsStore";
|
||||||
|
|
||||||
|
const hex = (n: number) =>
|
||||||
|
Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join("");
|
||||||
|
|
||||||
|
/** Mint a collectible to the (stub) chain, owned by `owner`. */
|
||||||
|
export function mintProvenance(owner: string): Provenance {
|
||||||
|
return {
|
||||||
|
tokenId: String(Math.floor(Math.random() * 1_000_000)),
|
||||||
|
txHash: `0x${hex(64)}`,
|
||||||
|
chain: "Base (stub)",
|
||||||
|
owner: owner.trim() || "anonymous",
|
||||||
|
mintedAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user