web #1

Merged
shafin808s merged 35 commits from web into main 2026-03-11 20:41:06 +00:00
194 changed files with 89132 additions and 2570 deletions
Showing only changes of commit 79fc2eacdc - Show all commits

View File

@ -1,4 +1,5 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react"; import { X } from "lucide-react";
import type { InventoryItem, ActiveEffect } from "../types/quest"; import type { InventoryItem, ActiveEffect } from "../types/quest";
import { import {
@ -43,7 +44,6 @@ const STYLES = `
to { transform: translateY(0); opacity:1; } to { transform: translateY(0); opacity:1; }
} }
/* Sea shimmer bg */
.inv-sheet::before { .inv-sheet::before {
content: ''; content: '';
position: absolute; inset: 0; pointer-events: none; z-index: 0; position: absolute; inset: 0; pointer-events: none; z-index: 0;
@ -58,7 +58,6 @@ const STYLES = `
100% { background-position: 100% 100%, 0% 100%; } 100% { background-position: 100% 100%, 0% 100%; }
} }
/* Gold orb top-right */
.inv-sheet::after { .inv-sheet::after {
content: ''; content: '';
position: absolute; top: -60px; right: -40px; z-index: 0; position: absolute; top: -60px; right: -40px; z-index: 0;
@ -67,7 +66,6 @@ const STYLES = `
pointer-events: none; pointer-events: none;
} }
/* ── Handle ── */
.inv-handle-row { .inv-handle-row {
display: flex; justify-content: center; display: flex; justify-content: center;
padding: 0.75rem 0 0; flex-shrink: 0; position: relative; z-index: 2; 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); background: rgba(255,255,255,0.1);
} }
/* ── Header ── */
.inv-header { .inv-header {
position: relative; z-index: 2; position: relative; z-index: 2;
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
@ -108,7 +105,6 @@ const STYLES = `
background: rgba(251,191,36,0.1); background: rgba(251,191,36,0.1);
} }
/* ── Active effects banner ── */
.inv-active-bar { .inv-active-bar {
position: relative; z-index: 2; position: relative; z-index: 2;
display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: none; display: flex; gap: 0.5rem; overflow-x: auto; scrollbar-width: none;
@ -140,7 +136,6 @@ const STYLES = `
margin-left: 0.1rem; margin-left: 0.1rem;
} }
/* ── Divider ── */
.inv-divider { .inv-divider {
position: relative; z-index: 2; position: relative; z-index: 2;
height: 1px; margin: 0.85rem 1.3rem 0; height: 1px; margin: 0.85rem 1.3rem 0;
@ -154,7 +149,6 @@ const STYLES = `
text-transform: uppercase; color: rgba(255,255,255,0.25); text-transform: uppercase; color: rgba(255,255,255,0.25);
} }
/* ── Scrollable item grid ── */
.inv-scroll { .inv-scroll {
position: relative; z-index: 2; position: relative; z-index: 2;
flex: 1; overflow-y: auto; scrollbar-width: none; flex: 1; overflow-y: auto; scrollbar-width: none;
@ -162,7 +156,6 @@ const STYLES = `
} }
.inv-scroll::-webkit-scrollbar { display: none; } .inv-scroll::-webkit-scrollbar { display: none; }
/* ── Empty state ── */
.inv-empty { .inv-empty {
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 0.6rem; justify-content: center; gap: 0.6rem;
@ -173,7 +166,6 @@ const STYLES = `
} }
.inv-empty-icon { font-size: 2.5rem; opacity: 0.4; } .inv-empty-icon { font-size: 2.5rem; opacity: 0.4; }
/* ── Loading skeleton ── */
.inv-skeleton-grid { .inv-skeleton-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
} }
@ -187,12 +179,10 @@ const STYLES = `
50% { opacity: 1; } 50% { opacity: 1; }
} }
/* ── Item grid ── */
.inv-grid { .inv-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem;
} }
/* ── Item card ── */
.inv-card { .inv-card {
border-radius: 20px; padding: 1rem; border-radius: 20px; padding: 1rem;
border: 1.5px solid rgba(255,255,255,0.07); border: 1.5px solid rgba(255,255,255,0.07);
@ -213,8 +203,6 @@ const STYLES = `
transform: translateY(-2px); transform: translateY(-2px);
} }
.inv-card:active { transform: translateY(0) scale(0.98); } .inv-card:active { transform: translateY(0) scale(0.98); }
/* Active card styling */
.inv-card.is-active { .inv-card.is-active {
border-color: rgba(251,191,36,0.4); border-color: rgba(251,191,36,0.4);
background: rgba(251,191,36,0.06); background: rgba(251,191,36,0.06);
@ -223,26 +211,17 @@ const STYLES = `
border-color: rgba(251,191,36,0.6); border-color: rgba(251,191,36,0.6);
background: rgba(251,191,36,0.09); background: rgba(251,191,36,0.09);
} }
/* Just-activated flash */
@keyframes invActivateFlash { @keyframes invActivateFlash {
0% { background: rgba(251,191,36,0.25); border-color: rgba(251,191,36,0.8); } 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); } 100%{ background: rgba(251,191,36,0.06); border-color: rgba(251,191,36,0.4); }
} }
.inv-card.just-activated { .inv-card.just-activated { animation: invActivateFlash 0.9s ease forwards; }
animation: invActivateFlash 0.9s ease forwards;
}
/* Card shimmer overlay */
.inv-card-sheen { .inv-card-sheen {
position: absolute; inset: 0; pointer-events: none; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.04) 50%, transparent 70%); background: linear-gradient(135deg, transparent 30%, rgba(255,255,255,0.04) 50%, transparent 70%);
transform: translateX(-100%); transform: translateX(-100%); transition: transform 0.5s ease;
transition: transform 0.5s ease;
} }
.inv-card:hover .inv-card-sheen { transform: translateX(100%); } .inv-card:hover .inv-card-sheen { transform: translateX(100%); }
/* Icon area */
.inv-card-icon-wrap { .inv-card-icon-wrap {
width: 44px; height: 44px; border-radius: 14px; width: 44px; height: 44px; border-radius: 14px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
@ -258,30 +237,23 @@ const STYLES = `
.inv-card-active-dot { .inv-card-active-dot {
position: absolute; top: -3px; right: -3px; position: absolute; top: -3px; right: -3px;
width: 10px; height: 10px; border-radius: 50%; width: 10px; height: 10px; border-radius: 50%;
background: #fbbf24; background: #fbbf24; border: 2px solid #08111f;
border: 2px solid #08111f;
animation: invDotPulse 2s ease-in-out infinite; animation: invDotPulse 2s ease-in-out infinite;
} }
@keyframes invDotPulse { @keyframes invDotPulse {
0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,0.6); } 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); } 50% { box-shadow: 0 0 0 5px rgba(251,191,36,0); }
} }
/* Card text */
.inv-card-name { .inv-card-name {
font-family: 'Nunito', sans-serif; font-family: 'Nunito', sans-serif;
font-size: 0.82rem; font-weight: 900; color: #fff; font-size: 0.82rem; font-weight: 900; color: #fff; line-height: 1.2;
line-height: 1.2;
} }
.inv-card.is-active .inv-card-name { color: #fbbf24; } .inv-card.is-active .inv-card-name { color: #fbbf24; }
.inv-card-desc { .inv-card-desc {
font-family: 'Nunito Sans', sans-serif; font-family: 'Nunito Sans', sans-serif;
font-size: 0.63rem; font-weight: 600; font-size: 0.63rem; font-weight: 600;
color: rgba(255,255,255,0.38); line-height: 1.4; color: rgba(255,255,255,0.38); line-height: 1.4; flex: 1;
flex: 1;
} }
/* Qty + type row */
.inv-card-meta { .inv-card-meta {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
gap: 0.4rem; margin-top: auto; gap: 0.4rem; margin-top: auto;
@ -299,11 +271,8 @@ const STYLES = `
letter-spacing: 0.1em; text-transform: uppercase; letter-spacing: 0.1em; text-transform: uppercase;
color: rgba(255,255,255,0.22); color: rgba(255,255,255,0.22);
} }
/* Activate button */
.inv-activate-btn { .inv-activate-btn {
width: 100%; width: 100%; padding: 0.48rem;
padding: 0.48rem;
border-radius: 10px; border: none; cursor: pointer; border-radius: 10px; border: none; cursor: pointer;
font-family: 'Nunito', sans-serif; font-family: 'Nunito', sans-serif;
font-size: 0.7rem; font-weight: 900; font-size: 0.7rem; font-weight: 900;
@ -315,10 +284,7 @@ const STYLES = `
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.6); color: rgba(255,255,255,0.6);
} }
.inv-activate-btn.idle:hover { .inv-activate-btn.idle:hover { background: rgba(255,255,255,0.12); color: white; }
background: rgba(255,255,255,0.12);
color: white;
}
.inv-activate-btn.activating { .inv-activate-btn.activating {
background: rgba(251,191,36,0.1); background: rgba(251,191,36,0.1);
border: 1px solid rgba(251,191,36,0.25); border: 1px solid rgba(251,191,36,0.25);
@ -330,8 +296,7 @@ const STYLES = `
.inv-activate-btn.active-state { .inv-activate-btn.active-state {
background: rgba(251,191,36,0.12); background: rgba(251,191,36,0.12);
border: 1px solid rgba(251,191,36,0.3); border: 1px solid rgba(251,191,36,0.3);
color: #fbbf24; color: #fbbf24; cursor: default;
cursor: default;
} }
.inv-activate-btn.success-flash { .inv-activate-btn.success-flash {
background: rgba(74,222,128,0.18); background: rgba(74,222,128,0.18);
@ -339,24 +304,17 @@ const STYLES = `
color: #4ade80; color: #4ade80;
animation: invSuccessScale 0.35s cubic-bezier(0.34,1.56,0.64,1) both; animation: invSuccessScale 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
} }
@keyframes invSuccessScale { @keyframes invSuccessScale { from{transform:scale(0.94)} to{transform:scale(1)} }
from { transform: scale(0.94); }
to { transform: scale(1); }
}
.inv-activate-btn:disabled { pointer-events: none; } .inv-activate-btn:disabled { pointer-events: none; }
/* Time remaining on active button */
.inv-active-time { .inv-active-time {
font-family: 'Nunito Sans', sans-serif; font-family: 'Nunito Sans', sans-serif;
font-size: 0.55rem; font-weight: 700; font-size: 0.55rem; font-weight: 700; color: rgba(251,191,36,0.5);
color: rgba(251,191,36,0.5);
} }
/* ── Toast ── */
.inv-toast { .inv-toast {
position: fixed; bottom: calc(1.5rem + env(safe-area-inset-bottom)); position: fixed; bottom: calc(1.5rem + env(safe-area-inset-bottom));
left: 50%; transform: translateX(-50%); left: 50%; transform: translateX(-50%);
z-index: 90; z-index: 9999;
display: flex; align-items: center; gap: 0.55rem; display: flex; align-items: center; gap: 0.55rem;
padding: 0.7rem 1.2rem; padding: 0.7rem 1.2rem;
background: linear-gradient(135deg, #1a3a1a, #0d2010); background: linear-gradient(135deg, #1a3a1a, #0d2010);
@ -380,13 +338,11 @@ const ITEM_ICON: Record<string, string> = {
title: "🏴‍☠️", title: "🏴‍☠️",
coin_boost: "🪙", coin_boost: "🪙",
}; };
const ITEM_ICON_DEFAULT = "📦";
function itemIcon(effectType: string): string { 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( function isItemActive(
item: InventoryItem, item: InventoryItem,
activeEffects: ActiveEffect[], activeEffects: ActiveEffect[],
@ -438,33 +394,23 @@ const ItemCard = ({
style={{ "--ci-delay": `${index * 0.045}s` } as React.CSSProperties} style={{ "--ci-delay": `${index * 0.045}s` } as React.CSSProperties}
> >
<div className="inv-card-sheen" /> <div className="inv-card-sheen" />
{/* Icon */}
<div className="inv-card-icon-wrap"> <div className="inv-card-icon-wrap">
{itemIcon(inv.item.effect_type)} {itemIcon(inv.item.effect_type)}
{isActive && <div className="inv-card-active-dot" />} {isActive && <div className="inv-card-active-dot" />}
</div> </div>
{/* Name + description */}
<p className="inv-card-name">{inv.item.name}</p> <p className="inv-card-name">{inv.item.name}</p>
<p className="inv-card-desc">{inv.item.description}</p> <p className="inv-card-desc">{inv.item.description}</p>
{/* Qty + type */}
<div className="inv-card-meta"> <div className="inv-card-meta">
<span className="inv-card-qty">×{inv.quantity}</span> <span className="inv-card-qty">×{inv.quantity}</span>
<span className="inv-card-type"> <span className="inv-card-type">
{inv.item.type.replace(/_/g, " ")} {inv.item.type.replace(/_/g, " ")}
</span> </span>
</div> </div>
{/* Time remaining if active */}
{isActive && activeEffect && ( {isActive && activeEffect && (
<div className="inv-active-time"> <div className="inv-active-time">
{formatTimeLeft(activeEffect.expires_at)} remaining {formatTimeLeft(activeEffect.expires_at)} remaining
</div> </div>
)} )}
{/* Activate button */}
<button <button
className={`inv-activate-btn ${btnState}`} className={`inv-activate-btn ${btnState}`}
onClick={() => !isActive && !isActivating && onActivate(inv.id)} onClick={() => !isActive && !isActivating && onActivate(inv.id)}
@ -504,11 +450,9 @@ export const InventoryModal = ({ onClose }: Props) => {
const [toastMsg, setToastMsg] = useState(""); const [toastMsg, setToastMsg] = useState("");
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── Fetch on open ──────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
let cancelled = false; let cancelled = false;
const fetchInv = async () => { const fetchInv = async () => {
setLoading(true); setLoading(true);
try { try {
@ -520,31 +464,24 @@ export const InventoryModal = ({ onClose }: Props) => {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
}; };
fetchInv(); fetchInv();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [token]); }, [token]);
// ── Activate ──────────────────────────────────────────────────────────────
const handleActivate = useCallback( const handleActivate = useCallback(
async (itemId: string) => { async (itemId: string) => {
if (!token) return; if (!token) return;
activateItemOptimistic(itemId); activateItemOptimistic(itemId);
try { try {
const updatedInv = await api.activateItem(token, itemId); const updatedInv = await api.activateItem(token, itemId);
activateItemSuccess(updatedInv, itemId); activateItemSuccess(updatedInv, itemId);
// Find item name for toast
const name = items.find((i) => i.id === itemId)?.item.name ?? "Item"; const name = items.find((i) => i.id === itemId)?.item.name ?? "Item";
setToastMsg( setToastMsg(
`${itemIcon(items.find((i) => i.id === itemId)?.item.effect_type ?? "")} ${name} activated!`, `${itemIcon(items.find((i) => i.id === itemId)?.item.effect_type ?? "")} ${name} activated!`,
); );
setShowToast(true); setShowToast(true);
// Auto-clear success state + toast
if (toastTimer.current) clearTimeout(toastTimer.current); if (toastTimer.current) clearTimeout(toastTimer.current);
toastTimer.current = setTimeout(() => { toastTimer.current = setTimeout(() => {
setShowToast(false); setShowToast(false);
@ -560,7 +497,6 @@ export const InventoryModal = ({ onClose }: Props) => {
[token, items], [token, items],
); );
// Cleanup timer on unmount
useEffect( useEffect(
() => () => { () => () => {
if (toastTimer.current) clearTimeout(toastTimer.current); if (toastTimer.current) clearTimeout(toastTimer.current);
@ -570,18 +506,19 @@ export const InventoryModal = ({ onClose }: Props) => {
const liveEffects = getLiveEffects(activeEffects); 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> <style>{STYLES}</style>
<div className="inv-overlay" onClick={onClose}> <div className="inv-overlay" onClick={onClose}>
<div className="inv-sheet" onClick={(e) => e.stopPropagation()}> <div className="inv-sheet" onClick={(e) => e.stopPropagation()}>
{/* Handle */}
<div className="inv-handle-row"> <div className="inv-handle-row">
<div className="inv-handle" /> <div className="inv-handle" />
</div> </div>
{/* Header */}
<div className="inv-header"> <div className="inv-header">
<div className="inv-header-left"> <div className="inv-header-left">
<span className="inv-eyebrow"> Pirate's Hold</span> <span className="inv-eyebrow"> Pirate's Hold</span>
@ -592,7 +529,6 @@ export const InventoryModal = ({ onClose }: Props) => {
</button> </button>
</div> </div>
{/* Active effects bar */}
{liveEffects.length > 0 && ( {liveEffects.length > 0 && (
<div className="inv-active-bar"> <div className="inv-active-bar">
{liveEffects.map((e) => ( {liveEffects.map((e) => (
@ -616,7 +552,6 @@ export const InventoryModal = ({ onClose }: Props) => {
: "Your hold"} : "Your hold"}
</p> </p>
{/* Scroll area */}
<div className="inv-scroll"> <div className="inv-scroll">
{loading && items.length === 0 ? ( {loading && items.length === 0 ? (
<div className="inv-skeleton-grid"> <div className="inv-skeleton-grid">
@ -649,7 +584,6 @@ export const InventoryModal = ({ onClose }: Props) => {
</div> </div>
)} )}
{/* Error inline */}
{error && ( {error && (
<p <p
style={{ style={{
@ -668,8 +602,8 @@ export const InventoryModal = ({ onClose }: Props) => {
</div> </div>
</div> </div>
{/* Success toast */}
{showToast && <div className="inv-toast">{toastMsg}</div>} {showToast && <div className="inv-toast">{toastMsg}</div>}
</> </>,
document.body,
); );
}; };