8-bit Save Slots

PreviousNext

A retro-styled save/load game interface with slot previews, timestamps, and empty slot handling.

Docs
8bitcnblock

Preview

Loading preview…
components/ui/8bit/save-slots.tsx
"use client";

import * as React from "react";

import { cva } from "class-variance-authority";

import { cn } from "@/lib/utils";

import { Badge } from "@/components/ui/8bit/badge";
import { Button } from "@/components/ui/8bit/button";
import {
    Card,
    CardContent,
    CardHeader,
    CardTitle,
} from "@/components/ui/8bit/card";
import { Separator } from "@/components/ui/8bit/separator";

import "./styles/retro.css";

export interface SaveSlot {
    id: string;
    isEmpty: boolean;
    name?: string;
    timestamp?: Date | string;
    preview?: string;
    description?: string;
}

export interface SaveSlotsProps extends React.ComponentProps<"div"> {
    slots: SaveSlot[];
    onSlotClick?: (slot: SaveSlot) => void;
    layout?: "vertical" | "grid";
    maxSlots?: number;
    maxVisibleSlots?: number;
    className?: string;
    title?: string;
    showTimestamp?: boolean;
    showPreview?: boolean;
}

const slotItemVariants = cva(
    "group relative flex gap-2 sm:gap-4 p-2 sm:p-4 transition-all duration-200 cursor-pointer border-2",
    {
        variants: {
            state: {
                empty:
                    "border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-accent/30",
                filled:
                    "border-border hover:border-primary hover:bg-accent/50 bg-muted/30",
            },
        },
        defaultVariants: {
            state: "empty",
        },
    }
);

const previewFrameVariants = cva(
    "flex items-center justify-center border-4 overflow-hidden flex-shrink-0",
    {
        variants: {
            state: {
                empty: "border-dashed border-muted-foreground/40 bg-muted/10",
                filled: "border-foreground dark:border-ring bg-background",
            },
        },
        defaultVariants: {
            state: "empty",
        },
    }
);

// Floppy disk SVG icon for empty slots
function FloppyIcon({ className }: { className?: string }) {
    return (
        <svg
            className={className}
            fill="currentColor"
            viewBox="0 0 24 24"
            xmlns="http://www.w3.org/2000/svg"
        >
            <path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm0 2v14h14V5H5zm2 2h6v4H7V7zm8 0h2v3h-2V7zm-8 6h10v4H7v-4z" />
        </svg>
    );
}

function formatTimestamp(timestamp: Date | string): string {
    const date = typeof timestamp === "string" ? new Date(timestamp) : timestamp;

    if (!date || Number.isNaN(date.getTime())) {
        return "Unknown date";
    }

    const now = new Date();
    const diff = now.getTime() - date.getTime();
    const days = Math.floor(diff / (1000 * 60 * 60 * 24));

    if (days === 0) {
        return "Today";
    }
    if (days === 1) {
        return "Yesterday";
    }
    if (days < 7) {
        return `${days} days ago`;
    }

    return date.toLocaleDateString("en-US", {
        month: "short",
        day: "numeric",
        year: "numeric",
    });
}

function SlotPreview({
    preview,
    isEmpty,
    slotNumber,
}: {
    preview?: string;
    isEmpty: boolean;
    slotNumber: number;
}) {
    const state = isEmpty ? "empty" : "filled";

    return (
        <div
            aria-hidden="true"
            className={cn(previewFrameVariants({ state }), "size-14 sm:size-20 md:size-24")}
        >
            {isEmpty ? (
                <div className="flex flex-col items-center gap-0.5 sm:gap-1">
                    <FloppyIcon className="size-5 sm:size-8 text-muted-foreground/50" />
                    <span className="retro text-[8px] sm:text-[10px] text-muted-foreground/50">
                        {slotNumber}
                    </span>
                </div>
            ) : preview ? (
                <img
                    alt="Save preview"
                    className="size-full object-cover pixelated"
                    loading="lazy"
                    src={preview}
                />
            ) : (
                <div className="flex flex-col items-center gap-0.5 sm:gap-1">
                    <FloppyIcon className="size-5 sm:size-8 text-muted-foreground" />
                    <span className="retro text-[8px] sm:text-[10px] text-muted-foreground">
                        {slotNumber}
                    </span>
                </div>
            )}
        </div>
    );
}

function SlotItem({
    slot,
    index,
    showPreview,
    showTimestamp,
    onClick,
}: {
    slot: SaveSlot;
    index: number;
    showPreview: boolean;
    showTimestamp: boolean;
    onClick?: (slot: SaveSlot) => void;
}) {
    const state = slot.isEmpty ? "empty" : "filled";
    const slotNumber = index + 1;
    const displayName = slot.name || `Slot ${slotNumber}`;

    const handleClick = () => {
        onClick?.(slot);
    };

    const handleKeyDown = (e: React.KeyboardEvent) => {
        if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            onClick?.(slot);
        }
    };

    return (
        <div
            className={cn(slotItemVariants({ state }), "overflow-hidden")}
            onClick={handleClick}
            onKeyDown={handleKeyDown}
            role="button"
            tabIndex={0}
            aria-label={
                slot.isEmpty
                    ? `Empty save slot ${slotNumber}`
                    : `Save slot ${slotNumber}: ${displayName}`
            }
        >
            {showPreview && (
                <SlotPreview
                    preview={slot.preview}
                    isEmpty={slot.isEmpty}
                    slotNumber={slotNumber}
                />
            )}

            <div className="flex-1 min-w-0 flex flex-col justify-center gap-1 sm:gap-2 overflow-hidden">
                <div className="flex items-center gap-1 sm:gap-2">
                    <h3
                        className={cn(
                            "retro text-[10px] sm:text-xs md:text-sm font-medium truncate flex-1 min-w-0",
                            slot.isEmpty && "text-muted-foreground"
                        )}
                    >
                        {slot.isEmpty ? `EMPTY ${slotNumber}` : displayName}
                    </h3>
                    {!slot.isEmpty && (
                        <Badge className="text-[7px] sm:text-[9px] retro shrink-0 hidden sm:inline-flex" variant="secondary">
                            SAVED
                        </Badge>
                    )}
                </div>

                {!slot.isEmpty && (
                    <>
                        {slot.description && (
                            <p className="text-[9px] sm:text-xs text-muted-foreground truncate">
                                {slot.description}
                            </p>
                        )}
                        {showTimestamp && slot.timestamp && (
                            <p className="text-[8px] sm:text-[10px] text-muted-foreground/70 retro">
                                {formatTimestamp(slot.timestamp)}
                            </p>
                        )}
                    </>
                )}

                {slot.isEmpty && (
                    <p className="text-[9px] sm:text-xs text-muted-foreground/60 retro">
                        Click to save
                    </p>
                )}
            </div>

            {!slot.isEmpty && (
                <div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
                    <Button
                        variant="ghost"
                        size="sm"
                        className="text-xs h-auto py-1 px-2"
                        onClick={(e) => {
                            e.stopPropagation();
                            onClick?.(slot);
                        }}
                        aria-label={`Load save from slot ${slotNumber}`}
                    >
                        LOAD
                    </Button>
                </div>
            )}
        </div>
    );
}

export function SaveSlots({
    slots,
    onSlotClick,
    layout = "vertical",
    maxSlots = 10,
    maxVisibleSlots = 4,
    className,
    title = "SAVE FILES",
    showTimestamp = true,
    showPreview = true,
    ...props
}: SaveSlotsProps) {
    const displaySlots = React.useMemo(() => {
        return slots.slice(0, maxSlots);
    }, [slots, maxSlots]);

    const savedCount = React.useMemo(() => {
        return displaySlots.filter((slot) => !slot.isEmpty).length;
    }, [displaySlots]);

    const containerClassName = cn(
        "gap-3",
        layout === "grid"
            ? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
            : "flex flex-col"
    );

    // Calculate scroll area height: each slot is ~100px + 12px gap
    const slotHeight = 100;
    const gapHeight = 12;
    const scrollHeight = maxVisibleSlots * slotHeight + (maxVisibleSlots - 1) * gapHeight;
    const needsScroll = displaySlots.length > maxVisibleSlots && layout === "vertical";

    const slotsContent = (
        <div className={containerClassName}>
            {displaySlots.length === 0 ? (
                <div className="text-center py-8 text-muted-foreground col-span-full">
                    <p className="retro text-sm">No save slots available</p>
                </div>
            ) : (
                displaySlots.map((slot, index) => (
                    <SlotItem
                        key={slot.id}
                        index={index}
                        onClick={onSlotClick}
                        showPreview={showPreview}
                        showTimestamp={showTimestamp}
                        slot={slot}
                    />
                ))
            )}
        </div>
    );

    return (
        <Card
            className={className}
            data-slot="save-slots"
            font="retro"
            {...props}
        >
            {title && (
                <CardHeader>
                    <CardTitle className="text-center flex items-center justify-center gap-3 flex-wrap">
                        <span>{title}</span>
                        {savedCount > 0 && (
                            <Badge className="text-[9px]" variant="default">
                                {savedCount} SAVED
                            </Badge>
                        )}
                    </CardTitle>
                </CardHeader>
            )}

            <CardContent className="space-y-5">
                {needsScroll ? (
                    <div
                        className="overflow-y-auto"
                        style={{ maxHeight: `${scrollHeight}px` }}
                    >
                        {slotsContent}
                    </div>
                ) : (
                    slotsContent
                )}

                {displaySlots.length > 0 && (
                    <>
                        <Separator />
                        <div className="mt-4 pt-4">
                            <p
                                className={cn("text-xs text-muted-foreground text-center retro")}
                            >
                                {savedCount} of {displaySlots.length} slots used
                            </p>
                        </div>
                    </>
                )}
            </CardContent>
        </Card>
    );
}

export default SaveSlots;

Installation

npx shadcn@latest add @8bitcn/save-slots

Usage

import { SaveSlots } from "@/components/save-slots"
<SaveSlots />