Frontend: prepinac vzhladu kariet (znaky vs suit-icon)
Toggle v hlavicke prepina znaky farieb medzi textovymi glyfmi a suit-icon obrazkami; volba perzistuje v localStorage. Gula ma v icon rezime hnedy popis, stredove ikonky srdca/gule/listu jemne zmensene aby sa neprekryvali s rohmi. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
import { useCardSkin } from '../lib/cardSkin';
|
||||||
|
|
||||||
|
/** Small header button that flips the deck between the classic vector cards and
|
||||||
|
* the illustrated "sedmové" art. Persists via the cardSkin store. */
|
||||||
|
export default function CardSkinToggle({ className = '' }: { className?: string }) {
|
||||||
|
const skin = useCardSkin((s) => s.skin);
|
||||||
|
const toggle = useCardSkin((s) => s.toggle);
|
||||||
|
const illustrated = skin === 'illustrated';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
title="Prepnúť vzhľad kariet"
|
||||||
|
aria-label="Prepnúť vzhľad kariet"
|
||||||
|
aria-pressed={illustrated}
|
||||||
|
className={`text-[13px] whitespace-nowrap transition-colors ${
|
||||||
|
illustrated ? 'text-gold hover:text-gold-bright' : 'text-[#7a7058] hover:text-gold'
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
{illustrated ? '🂠 Sedmové' : '🂠 Klasické'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Card } from '../types';
|
import type { Card } from '../types';
|
||||||
|
import { useCardSkin, suitIcon } from '../lib/cardSkin';
|
||||||
|
|
||||||
const SUIT_SYMBOL: Record<string, string> = {
|
const SUIT_SYMBOL: Record<string, string> = {
|
||||||
HEARTS: '♥',
|
HEARTS: '♥',
|
||||||
@@ -19,6 +20,15 @@ const VALUE_LABEL: Record<string, string> = {
|
|||||||
LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A',
|
LOWER: 'J', UPPER: 'Q', KING: 'K', ACE: 'A',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The bell, leaf and heart icons are a touch taller than the acorn, so in the
|
||||||
|
// centre they sometimes bleed into the corner marks — nudge those three down.
|
||||||
|
const CENTER_ICON_SCALE: Record<string, number> = {
|
||||||
|
HEARTS: 0.85,
|
||||||
|
LEAVES: 0.85,
|
||||||
|
BELLS: 0.85,
|
||||||
|
ACORNS: 1,
|
||||||
|
};
|
||||||
|
|
||||||
// Per-size geometry. sm/md sit on the table, lg/xl are hand cards.
|
// Per-size geometry. sm/md sit on the table, lg/xl are hand cards.
|
||||||
const DIMS = {
|
const DIMS = {
|
||||||
sm: { w: 38, h: 54, radius: 4, inset: 3, label: 9, suitSm: 7, suitLg: 18 },
|
sm: { w: 38, h: 54, radius: 4, inset: 3, label: 9, suitSm: 7, suitLg: 18 },
|
||||||
@@ -37,26 +47,45 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CardView({ card, onClick, disabled = false, highlight = false, size = 'md' }: Props) {
|
export default function CardView({ card, onClick, disabled = false, highlight = false, size = 'md' }: Props) {
|
||||||
|
const skin = useCardSkin((s) => s.skin);
|
||||||
const symbol = SUIT_SYMBOL[card.color];
|
const symbol = SUIT_SYMBOL[card.color];
|
||||||
const color = SUIT_COLOR[card.color];
|
const color = SUIT_COLOR[card.color];
|
||||||
const label = VALUE_LABEL[card.value];
|
const label = VALUE_LABEL[card.value];
|
||||||
const d = DIMS[size];
|
const d = DIMS[size];
|
||||||
|
|
||||||
const interactive = !disabled && !!onClick;
|
const interactive = !disabled && !!onClick;
|
||||||
|
// 'illustrated' skin keeps the classic card but swaps the ♥♠♣♦ glyphs for the
|
||||||
|
// suit-icon artwork; 'classic' uses the plain text glyphs.
|
||||||
|
const useIcons = skin === 'illustrated';
|
||||||
|
// With the icon artwork the bell suit reads brown, so tint its value label to
|
||||||
|
// match instead of the classic blue.
|
||||||
|
const labelColor = useIcons && card.color === 'BELLS' ? '#8a5a1c' : color;
|
||||||
|
|
||||||
const corner = (rotated: boolean) => (
|
const corner = (rotated: boolean) => (
|
||||||
<span
|
<span
|
||||||
className="absolute flex flex-col items-center leading-none"
|
// Icons align to the number's left edge so a wide label (VIII) doesn't shove
|
||||||
|
// the centred icon toward the middle; classic text stays centred as before.
|
||||||
|
className={`absolute flex flex-col leading-none ${useIcons ? 'items-start' : 'items-center'}`}
|
||||||
style={
|
style={
|
||||||
rotated
|
rotated
|
||||||
? { bottom: d.inset, right: d.inset, transform: 'rotate(180deg)' }
|
? { bottom: d.inset, right: d.inset, transform: 'rotate(180deg)' }
|
||||||
: { top: d.inset, left: d.inset }
|
: { top: d.inset, left: d.inset }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span style={{ color, fontSize: d.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
|
<span style={{ color: labelColor, fontSize: d.label, fontFamily: 'Georgia,serif', fontWeight: 700, lineHeight: 1 }}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color, fontSize: d.suitSm, lineHeight: 1.2 }}>{symbol}</span>
|
{useIcons ? (
|
||||||
|
<img
|
||||||
|
src={suitIcon(card.color, 'small')}
|
||||||
|
alt={symbol}
|
||||||
|
draggable={false}
|
||||||
|
style={{ height: d.suitSm * 1.25, width: 'auto', marginTop: 1 }}
|
||||||
|
className="select-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ color, fontSize: d.suitSm, lineHeight: 1.2 }}>{symbol}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -76,7 +105,17 @@ export default function CardView({ card, onClick, disabled = false, highlight =
|
|||||||
>
|
>
|
||||||
{corner(false)}
|
{corner(false)}
|
||||||
<span className="absolute inset-0 flex items-center justify-center">
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
<span style={{ color, fontSize: d.suitLg, lineHeight: 1 }}>{symbol}</span>
|
{useIcons ? (
|
||||||
|
<img
|
||||||
|
src={suitIcon(card.color, 'large')}
|
||||||
|
alt={symbol}
|
||||||
|
draggable={false}
|
||||||
|
style={{ height: d.suitLg * CENTER_ICON_SCALE[card.color], width: 'auto' }}
|
||||||
|
className="select-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ color, fontSize: d.suitLg, lineHeight: 1 }}>{symbol}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{corner(true)}
|
{corner(true)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { CardColor } from '../types';
|
||||||
|
|
||||||
|
// Two ways to draw the suit marks on the (otherwise identical) cards: plain
|
||||||
|
// ♥♠♣♦ text glyphs ('classic') or the "sedmové" suit-icon artwork ('illustrated').
|
||||||
|
export type CardSkin = 'classic' | 'illustrated';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'bridzik.cardSkin';
|
||||||
|
|
||||||
|
function loadSkin(): CardSkin {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) === 'illustrated' ? 'illustrated' : 'classic';
|
||||||
|
} catch {
|
||||||
|
return 'classic';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardSkinStore {
|
||||||
|
skin: CardSkin;
|
||||||
|
setSkin: (skin: CardSkin) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCardSkin = create<CardSkinStore>((set) => ({
|
||||||
|
skin: loadSkin(),
|
||||||
|
setSkin: (skin) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, skin);
|
||||||
|
} catch {
|
||||||
|
/* ignore — a private-mode failure just means the choice isn't persisted */
|
||||||
|
}
|
||||||
|
set({ skin });
|
||||||
|
},
|
||||||
|
toggle: () => set((s) => {
|
||||||
|
const skin: CardSkin = s.skin === 'illustrated' ? 'classic' : 'illustrated';
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, skin);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return { skin };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Filenames in /public/cards use their own suit/value words, not the engine's
|
||||||
|
// enum names — map between the two here.
|
||||||
|
const SUIT_FILE: Record<CardColor, string> = {
|
||||||
|
HEARTS: 'heart',
|
||||||
|
LEAVES: 'leaf',
|
||||||
|
ACORNS: 'acorn',
|
||||||
|
BELLS: 'bell',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Path to a suit icon at a given resolution, e.g. `/cards/suit-icons/heart-icon@small.png`.
|
||||||
|
* Used to render the suit marks on the classic vector cards. */
|
||||||
|
export function suitIcon(color: CardColor, size: 'small' | 'medium' | 'large'): string {
|
||||||
|
return `/cards/suit-icons/${SUIT_FILE[color]}-icon@${size}.png`;
|
||||||
|
}
|
||||||
@@ -8,12 +8,14 @@ import { computeTotal } from '../lib/standings';
|
|||||||
import { displayName } from '../lib/names';
|
import { displayName } from '../lib/names';
|
||||||
import { useIsDesktop } from '../lib/useIsDesktop';
|
import { useIsDesktop } from '../lib/useIsDesktop';
|
||||||
import { useFitScale } from '../lib/useFitScale';
|
import { useFitScale } from '../lib/useFitScale';
|
||||||
|
import { SEAT_SIZE, TOTALS_FONT } from '../lib/boardTheme';
|
||||||
import Hand from '../components/Hand';
|
import Hand from '../components/Hand';
|
||||||
import GuessControls from '../components/GuessControls';
|
import GuessControls from '../components/GuessControls';
|
||||||
import Trick from '../components/Trick';
|
import Trick from '../components/Trick';
|
||||||
import Standings from '../components/Standings';
|
import Standings from '../components/Standings';
|
||||||
import PlayerCircle from '../components/PlayerCircle';
|
import PlayerCircle from '../components/PlayerCircle';
|
||||||
import FaceDownCards from '../components/FaceDownCards';
|
import FaceDownCards from '../components/FaceDownCards';
|
||||||
|
import CardSkinToggle from '../components/CardSkinToggle';
|
||||||
import GameOver from './GameOver';
|
import GameOver from './GameOver';
|
||||||
import type { Hand as HandCards, PlayerInfo, StashData } from '../types';
|
import type { Hand as HandCards, PlayerInfo, StashData } from '../types';
|
||||||
|
|
||||||
@@ -205,19 +207,19 @@ export default function GameTable() {
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{opponents.map((p) => (
|
{opponents.map((p) => (
|
||||||
<div key={p.order} className="text-center">
|
<div key={p.order} className="text-center">
|
||||||
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: 11 }}>
|
<div className="uppercase tracking-[.1em] text-green-dim mb-0.5" style={{ fontSize: TOTALS_FONT.label }}>
|
||||||
{displayName(p.name)}
|
{displayName(p.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
<div className="font-serif text-green-score leading-none" style={{ fontSize: compact ? TOTALS_FONT.valueDesktop : TOTALS_FONT.valueMobile }}>
|
||||||
{computeTotal(standings, p.order)}
|
{computeTotal(standings, p.order)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="text-center rounded-lg px-3 py-1 bg-gold/[.06] border border-gold/[.15]">
|
<div className="text-center rounded-lg px-3 py-1 bg-gold/[.06] border border-gold/[.15]">
|
||||||
<div className="uppercase tracking-[.1em] text-gold mb-0.5" style={{ fontSize: 11 }}>
|
<div className="uppercase tracking-[.1em] text-gold mb-0.5" style={{ fontSize: TOTALS_FONT.label }}>
|
||||||
{myPlayer.name}
|
{myPlayer.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? 16 : 20 }}>
|
<div className="font-serif font-semibold text-gold-dim leading-none" style={{ fontSize: compact ? TOTALS_FONT.valueDesktop : TOTALS_FONT.valueMobile }}>
|
||||||
{computeTotal(standings, myOrder)}
|
{computeTotal(standings, myOrder)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,27 +243,30 @@ export default function GameTable() {
|
|||||||
myOrder={myOrder}
|
myOrder={myOrder}
|
||||||
activePlayer={active_player}
|
activePlayer={active_player}
|
||||||
activePlayerName={activePlayerName}
|
activePlayerName={activePlayerName}
|
||||||
|
desktop={desktop}
|
||||||
/>
|
/>
|
||||||
) : null
|
) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const seatSize = desktop ? SEAT_SIZE.desktop : SEAT_SIZE.mobile;
|
||||||
|
|
||||||
const topSeat = (
|
const topSeat = (
|
||||||
<div className="flex flex-col items-center gap-1.5">
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<PlayerCircle name={displayName(topP?.name) || '—'} {...seatProps(topP?.order)} size={desktop ? 64 : 52} />
|
<PlayerCircle name={displayName(topP?.name) || '—'} {...seatProps(topP?.order)} size={seatSize.top} />
|
||||||
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
|
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const sideSeat = (p?: PlayerInfo) => (
|
const sideSeat = (p?: PlayerInfo) => (
|
||||||
<div className="flex flex-col items-center gap-1.5">
|
<div className="flex flex-col items-center gap-1.5">
|
||||||
<PlayerCircle name={displayName(p?.name) || '—'} {...seatProps(p?.order)} size={desktop ? 60 : 48} />
|
<PlayerCircle name={displayName(p?.name) || '—'} {...seatProps(p?.order)} size={seatSize.side} />
|
||||||
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
|
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const meSeat = (
|
const meSeat = (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<PlayerCircle name={myPlayer.name} {...seatProps(myOrder)} size={desktop ? 70 : 58} />
|
<PlayerCircle name={myPlayer.name} {...seatProps(myOrder)} size={seatSize.me} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -283,22 +288,23 @@ export default function GameTable() {
|
|||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col">
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="shrink-0 h-[58px] bg-header flex items-center gap-4 px-6 border-b border-[#14221a]">
|
<div className="shrink-0 h-[58px] bg-header flex items-center gap-4 px-6 border-b border-[#14221a]">
|
||||||
<span className="font-serif uppercase tracking-[.14em] text-[15px] text-gold whitespace-nowrap">
|
<span className="font-serif uppercase tracking-[.14em] text-[18px] text-gold whitespace-nowrap">
|
||||||
Bridžik
|
Bridžik
|
||||||
</span>
|
</span>
|
||||||
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
||||||
<span className="font-serif text-[12px] text-green-dim tracking-[.06em] whitespace-nowrap">
|
<span className="font-serif text-[14px] text-green-dim tracking-[.06em] whitespace-nowrap">
|
||||||
Séria {series_number + 1} · Kolo {round_number + 1}
|
Séria {series_number + 1} · Kolo {round_number + 1}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 flex items-center justify-center">{banner}</div>
|
<div className="flex-1 flex items-center justify-center">{banner}</div>
|
||||||
{totalsRow(true)}
|
{totalsRow(true)}
|
||||||
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
<div className="w-px h-[22px] bg-[#1a3a22]" />
|
||||||
|
<CardSkinToggle />
|
||||||
{canEnd && (
|
{canEnd && (
|
||||||
<button onClick={handleEnd} className="text-[11px] text-[#8a8064] hover:text-gold whitespace-nowrap">
|
<button onClick={handleEnd} className="text-[13px] text-[#8a8064] hover:text-gold whitespace-nowrap">
|
||||||
Ukončiť
|
Ukončiť
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold whitespace-nowrap">
|
<button onClick={handleLeave} className="text-[13px] text-[#7a7058] hover:text-gold whitespace-nowrap">
|
||||||
Odísť
|
Odísť
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,16 +350,17 @@ export default function GameTable() {
|
|||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="bg-header px-[18px] pt-[14px] pb-3 border-b border-[#14221a]">
|
<div className="bg-header px-[18px] pt-[14px] pb-3 border-b border-[#14221a]">
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
<span className="font-serif uppercase tracking-[.1em] text-[11px] text-gold">
|
<span className="font-serif uppercase tracking-[.1em] text-[12px] text-gold">
|
||||||
Séria {series_number + 1} · Kolo {round_number + 1}
|
Séria {series_number + 1} · Kolo {round_number + 1}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<CardSkinToggle />
|
||||||
{canEnd && (
|
{canEnd && (
|
||||||
<button onClick={handleEnd} className="text-[11px] text-[#6a3030] hover:text-red-400">
|
<button onClick={handleEnd} className="text-[13px] text-[#6a3030] hover:text-red-400">
|
||||||
Ukončiť
|
Ukončiť
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleLeave} className="text-[11px] text-[#7a7058] hover:text-gold">
|
<button onClick={handleLeave} className="text-[13px] text-[#7a7058] hover:text-gold">
|
||||||
Odísť
|
Odísť
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||