Compare commits
5 Commits
e75233929a
...
a08476ec53
| Author | SHA1 | Date | |
|---|---|---|---|
| a08476ec53 | |||
| 437c7a517f | |||
| c35f328e30 | |||
| 79fc2eacdc | |||
| 9074b17a83 |
@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import type { InventoryItem, ActiveEffect } from "../types/quest";
|
||||
import {
|
||||
@ -43,7 +44,6 @@ const STYLES = `
|
||||
to { transform: translateY(0); opacity:1; }
|
||||
}
|
||||
|
||||
/* Sea shimmer bg */
|
||||
.inv-sheet::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0; pointer-events: none; z-index: 0;
|
||||
@ -58,7 +58,6 @@ const STYLES = `
|
||||
100% { background-position: 100% 100%, 0% 100%; }
|
||||
}
|
||||
|
||||
/* Gold orb top-right */
|
||||
.inv-sheet::after {
|
||||
content: '';
|
||||
position: absolute; top: -60px; right: -40px; z-index: 0;
|
||||
@ -67,7 +66,6 @@ const STYLES = `
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Handle ── */
|
||||
.inv-handle-row {
|
||||
display: flex; justify-content: center;
|
||||
padding: 0.75rem 0 0; flex-shrink: 0; position: relative; z-index: 2;
|
||||
@ -77,7 +75,6 @@ const STYLES = `
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.inv-header {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
@ -108,7 +105,6 @@ const STYLES = `
|
||||
background: rgba(251,191,36,0.1);
|
||||
}
|
||||
|
||||
/* ── Active effects banner ── */
|
||||
.inv-active-bar {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: none;
|
||||
@ -140,7 +136,6 @@ const STYLES = `
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
|
||||
/* ── Divider ── */
|
||||
.inv-divider {
|
||||
position: relative; z-index: 2;
|
||||
height: 1px; margin: 0.85rem 1.3rem 0;
|
||||
@ -154,7 +149,6 @@ const STYLES = `
|
||||
text-transform: uppercase; color: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
/* ── Scrollable item grid ── */
|
||||
.inv-scroll {
|
||||
position: relative; z-index: 2;
|
||||
flex: 1; overflow-y: auto; scrollbar-width: none;
|
||||
@ -162,7 +156,6 @@ const STYLES = `
|
||||
}
|
||||
.inv-scroll::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* ── Empty state ── */
|
||||
.inv-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 0.6rem;
|
||||
@ -173,7 +166,6 @@ const STYLES = `
|
||||
}
|
||||
.inv-empty-icon { font-size: 2.5rem; opacity: 0.4; }
|
||||
|
||||
/* ── Loading skeleton ── */
|
||||
.inv-skeleton-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
|
||||
}
|
||||
@ -187,12 +179,10 @@ const STYLES = `
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Item grid ── */
|
||||
.inv-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Item card ── */
|
||||
.inv-card {
|
||||
border-radius: 20px; padding: 1rem;
|
||||
border: 1.5px solid rgba(255,255,255,0.07);
|
||||
@ -213,8 +203,6 @@ const STYLES = `
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.inv-card:active { transform: translateY(0) scale(0.98); }
|
||||
|
||||
/* Active card styling */
|
||||
.inv-card.is-active {
|
||||
border-color: rgba(251,191,36,0.4);
|
||||
background: rgba(251,191,36,0.06);
|
||||
@ -223,26 +211,17 @@ const STYLES = `
|
||||
border-color: rgba(251,191,36,0.6);
|
||||
background: rgba(251,191,36,0.09);
|
||||
}
|
||||
|
||||
/* Just-activated flash */
|
||||
@keyframes invActivateFlash {
|
||||
0% { background: rgba(251,191,36,0.25); border-color: rgba(251,191,36,0.8); }
|
||||
100%{ background: rgba(251,191,36,0.06); border-color: rgba(251,191,36,0.4); }
|
||||
}
|
||||
.inv-card.just-activated {
|
||||
animation: invActivateFlash 0.9s ease forwards;
|
||||
}
|
||||
|
||||
/* Card shimmer overlay */
|
||||
.inv-card.just-activated { animation: invActivateFlash 0.9s ease forwards; }
|
||||
.inv-card-sheen {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.04) 50%, transparent 70%);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.5s ease;
|
||||
transform: translateX(-100%); transition: transform 0.5s ease;
|
||||
}
|
||||
.inv-card:hover .inv-card-sheen { transform: translateX(100%); }
|
||||
|
||||
/* Icon area */
|
||||
.inv-card-icon-wrap {
|
||||
width: 44px; height: 44px; border-radius: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
@ -258,30 +237,23 @@ const STYLES = `
|
||||
.inv-card-active-dot {
|
||||
position: absolute; top: -3px; right: -3px;
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: #fbbf24;
|
||||
border: 2px solid #08111f;
|
||||
background: #fbbf24; border: 2px solid #08111f;
|
||||
animation: invDotPulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes invDotPulse {
|
||||
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0.6); }
|
||||
50% { box-shadow: 0 0 0 5px rgba(251,191,36,0); }
|
||||
}
|
||||
|
||||
/* Card text */
|
||||
.inv-card-name {
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.82rem; font-weight: 900; color: #fff;
|
||||
line-height: 1.2;
|
||||
font-size: 0.82rem; font-weight: 900; color: #fff; line-height: 1.2;
|
||||
}
|
||||
.inv-card.is-active .inv-card-name { color: #fbbf24; }
|
||||
.inv-card-desc {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.63rem; font-weight: 600;
|
||||
color: rgba(255,255,255,0.38); line-height: 1.4;
|
||||
flex: 1;
|
||||
color: rgba(255,255,255,0.38); line-height: 1.4; flex: 1;
|
||||
}
|
||||
|
||||
/* Qty + type row */
|
||||
.inv-card-meta {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 0.4rem; margin-top: auto;
|
||||
@ -299,11 +271,8 @@ const STYLES = `
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: rgba(255,255,255,0.22);
|
||||
}
|
||||
|
||||
/* Activate button */
|
||||
.inv-activate-btn {
|
||||
width: 100%;
|
||||
padding: 0.48rem;
|
||||
width: 100%; padding: 0.48rem;
|
||||
border-radius: 10px; border: none; cursor: pointer;
|
||||
font-family: 'Nunito', sans-serif;
|
||||
font-size: 0.7rem; font-weight: 900;
|
||||
@ -315,10 +284,7 @@ const STYLES = `
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: rgba(255,255,255,0.6);
|
||||
}
|
||||
.inv-activate-btn.idle:hover {
|
||||
background: rgba(255,255,255,0.12);
|
||||
color: white;
|
||||
}
|
||||
.inv-activate-btn.idle:hover { background: rgba(255,255,255,0.12); color: white; }
|
||||
.inv-activate-btn.activating {
|
||||
background: rgba(251,191,36,0.1);
|
||||
border: 1px solid rgba(251,191,36,0.25);
|
||||
@ -330,8 +296,7 @@ const STYLES = `
|
||||
.inv-activate-btn.active-state {
|
||||
background: rgba(251,191,36,0.12);
|
||||
border: 1px solid rgba(251,191,36,0.3);
|
||||
color: #fbbf24;
|
||||
cursor: default;
|
||||
color: #fbbf24; cursor: default;
|
||||
}
|
||||
.inv-activate-btn.success-flash {
|
||||
background: rgba(74,222,128,0.18);
|
||||
@ -339,24 +304,17 @@ const STYLES = `
|
||||
color: #4ade80;
|
||||
animation: invSuccessScale 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
@keyframes invSuccessScale {
|
||||
from { transform: scale(0.94); }
|
||||
to { transform: scale(1); }
|
||||
}
|
||||
@keyframes invSuccessScale { from{transform:scale(0.94)} to{transform:scale(1)} }
|
||||
.inv-activate-btn:disabled { pointer-events: none; }
|
||||
|
||||
/* Time remaining on active button */
|
||||
.inv-active-time {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
font-size: 0.55rem; font-weight: 700;
|
||||
color: rgba(251,191,36,0.5);
|
||||
font-size: 0.55rem; font-weight: 700; color: rgba(251,191,36,0.5);
|
||||
}
|
||||
|
||||
/* ── Toast ── */
|
||||
.inv-toast {
|
||||
position: fixed; bottom: calc(1.5rem + env(safe-area-inset-bottom));
|
||||
left: 50%; transform: translateX(-50%);
|
||||
z-index: 90;
|
||||
z-index: 9999;
|
||||
display: flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.7rem 1.2rem;
|
||||
background: linear-gradient(135deg, #1a3a1a, #0d2010);
|
||||
@ -380,13 +338,11 @@ const ITEM_ICON: Record<string, string> = {
|
||||
title: "🏴☠️",
|
||||
coin_boost: "🪙",
|
||||
};
|
||||
const ITEM_ICON_DEFAULT = "📦";
|
||||
|
||||
function itemIcon(effectType: string): string {
|
||||
return ITEM_ICON[effectType] ?? ITEM_ICON_DEFAULT;
|
||||
return ITEM_ICON[effectType] ?? "📦";
|
||||
}
|
||||
|
||||
// ─── Check if an item is currently active ─────────────────────────────────────
|
||||
function isItemActive(
|
||||
item: InventoryItem,
|
||||
activeEffects: ActiveEffect[],
|
||||
@ -438,33 +394,23 @@ const ItemCard = ({
|
||||
style={{ "--ci-delay": `${index * 0.045}s` } as React.CSSProperties}
|
||||
>
|
||||
<div className="inv-card-sheen" />
|
||||
|
||||
{/* Icon */}
|
||||
<div className="inv-card-icon-wrap">
|
||||
{itemIcon(inv.item.effect_type)}
|
||||
{isActive && <div className="inv-card-active-dot" />}
|
||||
</div>
|
||||
|
||||
{/* Name + description */}
|
||||
<p className="inv-card-name">{inv.item.name}</p>
|
||||
<p className="inv-card-desc">{inv.item.description}</p>
|
||||
|
||||
{/* Qty + type */}
|
||||
<div className="inv-card-meta">
|
||||
<span className="inv-card-qty">×{inv.quantity}</span>
|
||||
<span className="inv-card-type">
|
||||
{inv.item.type.replace(/_/g, " ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Time remaining if active */}
|
||||
{isActive && activeEffect && (
|
||||
<div className="inv-active-time">
|
||||
{formatTimeLeft(activeEffect.expires_at)} remaining
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activate button */}
|
||||
<button
|
||||
className={`inv-activate-btn ${btnState}`}
|
||||
onClick={() => !isActive && !isActivating && onActivate(inv.id)}
|
||||
@ -504,11 +450,9 @@ export const InventoryModal = ({ onClose }: Props) => {
|
||||
const [toastMsg, setToastMsg] = useState("");
|
||||
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ── Fetch on open ──────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchInv = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -520,31 +464,24 @@ export const InventoryModal = ({ onClose }: Props) => {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchInv();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
// ── Activate ──────────────────────────────────────────────────────────────
|
||||
const handleActivate = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!token) return;
|
||||
activateItemOptimistic(itemId);
|
||||
|
||||
try {
|
||||
const updatedInv = await api.activateItem(token, itemId);
|
||||
activateItemSuccess(updatedInv, itemId);
|
||||
|
||||
// Find item name for toast
|
||||
const name = items.find((i) => i.id === itemId)?.item.name ?? "Item";
|
||||
setToastMsg(
|
||||
`${itemIcon(items.find((i) => i.id === itemId)?.item.effect_type ?? "")} ${name} activated!`,
|
||||
);
|
||||
setShowToast(true);
|
||||
|
||||
// Auto-clear success state + toast
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
toastTimer.current = setTimeout(() => {
|
||||
setShowToast(false);
|
||||
@ -560,7 +497,6 @@ export const InventoryModal = ({ onClose }: Props) => {
|
||||
[token, items],
|
||||
);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||
@ -570,18 +506,19 @@ export const InventoryModal = ({ onClose }: Props) => {
|
||||
|
||||
const liveEffects = getLiveEffects(activeEffects);
|
||||
|
||||
return (
|
||||
// Portal the entire modal to document.body so it always
|
||||
// renders at the top of the DOM tree, escaping any parent
|
||||
// stacking context, overflow:hidden, or z-index constraints.
|
||||
return createPortal(
|
||||
<>
|
||||
<style>{STYLES}</style>
|
||||
|
||||
<div className="inv-overlay" onClick={onClose}>
|
||||
<div className="inv-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Handle */}
|
||||
<div className="inv-handle-row">
|
||||
<div className="inv-handle" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="inv-header">
|
||||
<div className="inv-header-left">
|
||||
<span className="inv-eyebrow">⚓ Pirate's Hold</span>
|
||||
@ -592,7 +529,6 @@ export const InventoryModal = ({ onClose }: Props) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active effects bar */}
|
||||
{liveEffects.length > 0 && (
|
||||
<div className="inv-active-bar">
|
||||
{liveEffects.map((e) => (
|
||||
@ -616,7 +552,6 @@ export const InventoryModal = ({ onClose }: Props) => {
|
||||
: "Your hold"}
|
||||
</p>
|
||||
|
||||
{/* Scroll area */}
|
||||
<div className="inv-scroll">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="inv-skeleton-grid">
|
||||
@ -649,7 +584,6 @@ export const InventoryModal = ({ onClose }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error inline */}
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
@ -668,8 +602,8 @@ export const InventoryModal = ({ onClose }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success toast */}
|
||||
{showToast && <div className="inv-toast">{toastMsg}</div>}
|
||||
</>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { QuizData } from "../../types/lesson";
|
||||
import { type QuizData } from "../../types/lesson";
|
||||
import { CheckCircle2, XCircle, ChevronRight } from "lucide-react";
|
||||
|
||||
interface QuizProps {
|
||||
@ -22,11 +22,13 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
||||
if (!selectedId) return;
|
||||
setIsSubmitted(true);
|
||||
const selectedOption = data.options.find((opt) => opt.id === selectedId);
|
||||
const selectedOption = data.options.find((opt) => opt.id === selectedId);
|
||||
if (selectedOption?.isCorrect && onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
};
|
||||
|
||||
const selectedOption = data.options.find((opt) => opt.id === selectedId);
|
||||
const selectedOption = data.options.find((opt) => opt.id === selectedId);
|
||||
const isCorrect = selectedOption?.isCorrect;
|
||||
|
||||
@ -39,6 +41,12 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
||||
<p className="text-lg font-medium text-slate-900 mb-6 whitespace-pre-line">
|
||||
{data.question}
|
||||
</p>
|
||||
<h4 className="text-sm font-bold text-slate-400 uppercase tracking-wider mb-2">
|
||||
Concept Check
|
||||
</h4>
|
||||
<p className="text-lg font-medium text-slate-900 mb-6 whitespace-pre-line">
|
||||
{data.question}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{data.options.map((option) => {
|
||||
@ -58,9 +66,12 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
||||
} else if (option.isCorrect) {
|
||||
// Highlight correct answer if wrong one was picked
|
||||
borderClass = "border-green-200 bg-green-50/50";
|
||||
// Highlight correct answer if wrong one was picked
|
||||
borderClass = "border-green-200 bg-green-50/50";
|
||||
}
|
||||
} else if (selectedId === option.id) {
|
||||
borderClass = "border-indigo-600 bg-indigo-50";
|
||||
borderClass = "border-indigo-600 bg-indigo-50";
|
||||
}
|
||||
|
||||
return (
|
||||
@ -71,6 +82,17 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
||||
className={`w-full text-left p-4 rounded-lg border-2 transition-all duration-200 flex items-center justify-between group ${borderClass} ${bgClass}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={`w-6 h-6 flex items-center justify-center rounded-full text-xs font-bold mr-3 ${
|
||||
isSubmitted && option.isCorrect
|
||||
? "bg-green-200 text-green-800"
|
||||
: isSubmitted && option.id === selectedId
|
||||
? "bg-red-200 text-red-800"
|
||||
: selectedId === option.id
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-slate-100 text-slate-500"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-6 h-6 flex items-center justify-center rounded-full text-xs font-bold mr-3 ${
|
||||
isSubmitted && option.isCorrect
|
||||
@ -87,6 +109,9 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
||||
<span className="text-slate-700 group-hover:text-slate-900">
|
||||
{option.text}
|
||||
</span>
|
||||
<span className="text-slate-700 group-hover:text-slate-900">
|
||||
{option.text}
|
||||
</span>
|
||||
</div>
|
||||
{icon}
|
||||
</button>
|
||||
@ -97,6 +122,9 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
||||
|
||||
{/* Feedback Section */}
|
||||
{isSubmitted && (
|
||||
<div
|
||||
className={`p-6 border-t ${isCorrect ? "bg-green-50 border-green-100" : "bg-slate-50 border-slate-100"}`}
|
||||
>
|
||||
<div
|
||||
className={`p-6 border-t ${isCorrect ? "bg-green-50 border-green-100" : "bg-slate-50 border-slate-100"}`}
|
||||
>
|
||||
@ -124,6 +152,29 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
||||
{data.explanation}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 p-1 rounded-full ${isCorrect ? "bg-green-200" : "bg-slate-200"}`}
|
||||
>
|
||||
{isCorrect ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-700" />
|
||||
) : (
|
||||
<div className="w-4 h-4 text-slate-500 font-bold text-center leading-4">
|
||||
i
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className={`font-bold ${isCorrect ? "text-green-800" : "text-slate-800"} mb-1`}
|
||||
>
|
||||
{isCorrect ? "That's right!" : "Not quite."}
|
||||
</p>
|
||||
<p className="text-slate-600 mb-2">{selectedOption?.feedback}</p>
|
||||
<div className="text-sm text-slate-500 bg-white p-3 rounded border border-slate-200">
|
||||
<span className="font-semibold block mb-1">Explanation:</span>
|
||||
{data.explanation}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -137,6 +188,9 @@ const Quiz: React.FC<QuizProps> = ({ data, onComplete }) => {
|
||||
selectedId
|
||||
? "bg-slate-900 text-white hover:bg-slate-800 shadow-md transform hover:-translate-y-0.5"
|
||||
: "bg-slate-200 text-slate-400 cursor-not-allowed"
|
||||
selectedId
|
||||
? "bg-slate-900 text-white hover:bg-slate-800 shadow-md transform hover:-translate-y-0.5"
|
||||
: "bg-slate-200 text-slate-400 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
Check Answer <ChevronRight className="w-4 h-4 ml-1" />
|
||||
|
||||
Reference in New Issue
Block a user