Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 326 additions & 0 deletions packages/patterns/card-piles.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
/// <cts-enable />
import {
Cell,
computed,
Default,
handler,
lift,
NAME,
pattern,
UI,
} from "commontools";

// Card suits and ranks
const SUITS = ["hearts", "diamonds", "clubs", "spades"] as const;
const RANKS = [
"A",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"J",
"Q",
"K",
] as const;
type Suit = (typeof SUITS)[number];
type Rank = (typeof RANKS)[number];

// Card interface
interface Card {
suit: Suit;
rank: Rank;
}

// Generate unique random cards for defaults
const defaultPile1: Card[] = [
{ suit: "hearts", rank: "A" },
{ suit: "spades", rank: "K" },
{ suit: "diamonds", rank: "7" },
];

const defaultPile2: Card[] = [
{ suit: "clubs", rank: "Q" },
{ suit: "hearts", rank: "10" },
{ suit: "spades", rank: "3" },
];

interface CardPilesInput {
pile1: Default<Card[], typeof defaultPile1>;
pile2: Default<Card[], typeof defaultPile2>;
}

interface CardPilesOutput {
pile1: Card[];
pile2: Card[];
}

// Get suit symbol
const getSuitSymbol = lift((suit: Suit): string => {
switch (suit) {
case "hearts":
return "♥";
case "diamonds":
return "♦";
case "clubs":
return "♣";
case "spades":
return "♠";
}
});

// Get suit color
const getSuitColor = lift((suit: Suit): string => {
return suit === "hearts" || suit === "diamonds" ? "#dc2626" : "#1e293b";
});

// Handler to move a card to pile 1
const moveToPile1 = handler<
{ detail: { sourceCell: Cell } },
{ pile1: Cell<Card[]>; pile2: Cell<Card[]> }
>((event, { pile1, pile2 }) => {
const sourceCard = event.detail?.sourceCell?.get() as Card;
if (!sourceCard) return;

// Remove from pile2 if present
const p2 = pile2.get();
const idx2 = p2.findIndex(
(c) => c.rank === sourceCard.rank && c.suit === sourceCard.suit,
);
if (idx2 >= 0) {
pile2.set(p2.filter((_, i) => i !== idx2));
pile1.push(sourceCard);
}
});

// Handler to move a card to pile 2
const moveToPile2 = handler<
{ detail: { sourceCell: Cell } },
{ pile1: Cell<Card[]>; pile2: Cell<Card[]> }
>((event, { pile1, pile2 }) => {
const sourceCard = event.detail?.sourceCell?.get() as Card;
if (!sourceCard) return;

// Remove from pile1 if present
const p1 = pile1.get();
const idx1 = p1.findIndex(
(c) => c.rank === sourceCard.rank && c.suit === sourceCard.suit,
);
if (idx1 >= 0) {
pile1.set(p1.filter((_, i) => i !== idx1));
pile2.push(sourceCard);
}
});

export default pattern<CardPilesInput, CardPilesOutput>(({ pile1, pile2 }) => {
// Create computed versions to ensure reactivity
const cards1 = computed(() => pile1);
const cards2 = computed(() => pile2);

return {
[NAME]: "Card Piles",
[UI]: (
<div
style={{
display: "flex",
gap: "2rem",
padding: "1.5rem",
flexWrap: "wrap",
minHeight: "200px",
}}
>
<ct-drop-zone accept="card" onct-drop={moveToPile1({ pile1, pile2 })}>
<div
style={{
minWidth: "120px",
minHeight: "180px",
border: "2px dashed #cbd5e1",
borderRadius: "12px",
padding: "1rem",
background: "#f8fafc",
display: "flex",
flexDirection: "column",
gap: "0.5rem",
}}
>
<div
style={{
fontSize: "12px",
color: "#64748b",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
PILE 1
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
alignItems: "center",
}}
>
{cards1.map((card) => (
<ct-drag-source $cell={card} type="card">
<div
style={{
width: "70px",
height: "100px",
background: "white",
border: "2px solid #e2e8f0",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "6px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
cursor: "grab",
userSelect: "none",
}}
>
<div
style={{
color: getSuitColor(card.suit),
fontSize: "14px",
fontWeight: "bold",
}}
>
{card.rank}
<span style={{ marginLeft: "2px" }}>
{getSuitSymbol(card.suit)}
</span>
</div>
<div
style={{
color: getSuitColor(card.suit),
fontSize: "28px",
textAlign: "center",
lineHeight: "1",
}}
>
{getSuitSymbol(card.suit)}
</div>
<div
style={{
color: getSuitColor(card.suit),
fontSize: "14px",
fontWeight: "bold",
textAlign: "right",
transform: "rotate(180deg)",
}}
>
{card.rank}
<span style={{ marginLeft: "2px" }}>
{getSuitSymbol(card.suit)}
</span>
</div>
</div>
</ct-drag-source>
))}
</div>
</div>
</ct-drop-zone>

<ct-drop-zone accept="card" onct-drop={moveToPile2({ pile1, pile2 })}>
<div
style={{
minWidth: "120px",
minHeight: "180px",
border: "2px dashed #cbd5e1",
borderRadius: "12px",
padding: "1rem",
background: "#f8fafc",
display: "flex",
flexDirection: "column",
gap: "0.5rem",
}}
>
<div
style={{
fontSize: "12px",
color: "#64748b",
fontWeight: "600",
marginBottom: "0.5rem",
}}
>
PILE 2
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
alignItems: "center",
}}
>
{cards2.map((card) => (
<ct-drag-source $cell={card} type="card">
<div
style={{
width: "70px",
height: "100px",
background: "white",
border: "2px solid #e2e8f0",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "6px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
cursor: "grab",
userSelect: "none",
}}
>
<div
style={{
color: getSuitColor(card.suit),
fontSize: "14px",
fontWeight: "bold",
}}
>
{card.rank}
<span style={{ marginLeft: "2px" }}>
{getSuitSymbol(card.suit)}
</span>
</div>
<div
style={{
color: getSuitColor(card.suit),
fontSize: "28px",
textAlign: "center",
lineHeight: "1",
}}
>
{getSuitSymbol(card.suit)}
</div>
<div
style={{
color: getSuitColor(card.suit),
fontSize: "14px",
fontWeight: "bold",
textAlign: "right",
transform: "rotate(180deg)",
}}
>
{card.rank}
<span style={{ marginLeft: "2px" }}>
{getSuitSymbol(card.suit)}
</span>
</div>
</div>
</ct-drag-source>
))}
</div>
</div>
</ct-drop-zone>
</div>
),
pile1,
pile2,
};
});
Loading