fix: improve web auth refresh and mobile tutorials
This commit is contained in:
@@ -61,6 +61,13 @@ Login and public marketing/legal pages are not part of the authenticated app she
|
|||||||
- Mobile sidebar must be reachable through the menu button and must not hide the page content permanently.
|
- Mobile sidebar must be reachable through the menu button and must not hide the page content permanently.
|
||||||
- Public header mobile navigation must expose feature, pricing, about, login, and language switching.
|
- Public header mobile navigation must expose feature, pricing, about, login, and language switching.
|
||||||
|
|
||||||
|
### Mobile Guided Overlays
|
||||||
|
|
||||||
|
- Keep one dimming strategy per viewport. Do not combine a full-screen dark overlay with a spotlight element that also uses an oversized outer shadow on the same mobile viewport.
|
||||||
|
- Mobile spotlight targets should fit inside the phone viewport. If a desktop tutorial highlights a tall panel, use a smaller mobile-only target such as the rows or controls that the step actually explains.
|
||||||
|
- Tooltip placement and arrow direction must match: a tooltip above the target uses a bottom arrow pointing down; a tooltip below the target uses a top arrow pointing up.
|
||||||
|
- When the app shell owns scrolling, compute mobile overlay coordinates relative to the page component host and visible scroll container, not the document body.
|
||||||
|
|
||||||
## i18n Rules
|
## i18n Rules
|
||||||
|
|
||||||
- Supported locales: `zh`, `zh_Hant`, `en`.
|
- Supported locales: `zh`, `zh_Hant`, `en`.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"file": ".trellis/spec/web/index.md", "reason": "Verify mobile overlay behavior and web layout constraints."}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"file": ".trellis/spec/web/index.md", "reason": "Web authenticated app layout, responsive behavior, and mobile guided overlay rules."}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Fix mobile divination tutorial overlay
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Fix the mobile web tutorial overlay on manual and auto divination pages so spotlight targets, tooltip placement, arrow direction, and overlay shadow match the intended mobile UX.
|
||||||
|
|
||||||
|
## What I already know
|
||||||
|
|
||||||
|
* User reports mobile tutorial has an overly large dark/shadowed area.
|
||||||
|
* Step 3 should highlight the six yao area, not only the coin area. If the full coin + yao area is too large, highlight only the six yao rows and exclude the coins.
|
||||||
|
* Step 4 tooltip arrow should point down toward the spotlighted "start divination" button.
|
||||||
|
* Step 4 tooltip currently overlaps the spotlight region.
|
||||||
|
* Affected files are `web/src/components/ManualDivinationPage.tsx` and `web/src/components/AutoDivinationPage.tsx`.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
* Keep desktop behavior visually equivalent unless the existing code already uses a different desktop target.
|
||||||
|
* Mobile behavior is the priority for this task.
|
||||||
|
* No backend or protocol changes are needed.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
* Mobile step 3 highlights the six yao rows/panel area and excludes the coin selector.
|
||||||
|
* Mobile step 4 places the tooltip above the submit/start button with enough gap to avoid overlap.
|
||||||
|
* Mobile step 4 arrow points downward toward the spotlighted button.
|
||||||
|
* Remove the excessive/double-shadow appearance caused by overlapping dimming strategies.
|
||||||
|
* Apply the same behavior to manual and auto divination tutorial overlays.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
* [x] In mobile viewport, manual step 3 spotlight excludes the coin selector.
|
||||||
|
* [x] In mobile viewport, auto step 3 spotlight excludes the coin selector.
|
||||||
|
* [x] In mobile viewport, manual step 4 tooltip does not overlap the highlighted submit button and arrow points down.
|
||||||
|
* [x] In mobile viewport, auto step 4 tooltip does not overlap the highlighted submit button and arrow points down.
|
||||||
|
* [x] Overlay dimming is visually consistent without large unintended dark blocks.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
* Local browser verification at a phone-sized viewport.
|
||||||
|
* `git diff --check` passes.
|
||||||
|
* Build/typecheck status documented if blocked by existing project configuration.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
* Redesigning the tutorial copy.
|
||||||
|
* Changing tutorial persistence/profile settings behavior.
|
||||||
|
* Backend auth/session changes.
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
* Web spec applies: `.trellis/spec/web/index.md`.
|
||||||
|
* Existing mobile threshold uses `window.innerWidth < 1280`.
|
||||||
|
* App shell scroll container is the main vertical scrolling region.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "fix-mobile-divination-tutorial-overlay",
|
||||||
|
"name": "fix-mobile-divination-tutorial-overlay",
|
||||||
|
"title": "Fix mobile divination tutorial overlay",
|
||||||
|
"description": "",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "zl-q",
|
||||||
|
"assignee": "zl-q",
|
||||||
|
"createdAt": "2026-05-10",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "dev",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -200,6 +200,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
const coinsAreaRef = useRef<HTMLDivElement>(null);
|
const coinsAreaRef = useRef<HTMLDivElement>(null);
|
||||||
const timePanelRef = useRef<HTMLElement>(null);
|
const timePanelRef = useRef<HTMLElement>(null);
|
||||||
const yaoPanelRef = useRef<HTMLElement>(null);
|
const yaoPanelRef = useRef<HTMLElement>(null);
|
||||||
|
const yaoRowsRef = useRef<HTMLDivElement>(null);
|
||||||
const submitBtnRef = useRef<HTMLButtonElement>(null);
|
const submitBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
|
const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
|
||||||
@@ -253,12 +254,17 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetRef = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef][guideStep];
|
const mobileGuideTargets = [coinsAreaRef, timePanelRef, yaoRowsRef, submitBtnRef];
|
||||||
|
const desktopGuideTargets = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef];
|
||||||
|
const targetRef = (isMobile ? mobileGuideTargets : desktopGuideTargets)[guideStep];
|
||||||
if (!targetRef?.current) return;
|
if (!targetRef?.current) return;
|
||||||
|
|
||||||
const tooltipWidth = 320;
|
const tooltipWidth = 320;
|
||||||
const tooltipHeight = 180;
|
const tooltipHeight = isMobile ? 220 : 180;
|
||||||
const gap = 16;
|
const gap = isMobile ? 24 : 16;
|
||||||
|
|
||||||
|
const overlayHost = scrollContainerRef.current;
|
||||||
|
if (!overlayHost) return;
|
||||||
|
|
||||||
const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null;
|
const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null;
|
||||||
if (!scrollContainer) return;
|
if (!scrollContainer) return;
|
||||||
@@ -266,10 +272,9 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
const isInitialOpen = prevGuideStepRef.current === null;
|
const isInitialOpen = prevGuideStepRef.current === null;
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
const containerRect = scrollContainer.getBoundingClientRect();
|
|
||||||
const elementRect = targetRef.current.getBoundingClientRect();
|
const elementRect = targetRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
const elementLeft = elementRect.left - containerRect.left;
|
const containerRect = scrollContainer.getBoundingClientRect();
|
||||||
const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop;
|
const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop;
|
||||||
const elementWidth = elementRect.width;
|
const elementWidth = elementRect.width;
|
||||||
const elementHeight = elementRect.height;
|
const elementHeight = elementRect.height;
|
||||||
@@ -278,22 +283,37 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
scrollContainer.scrollTop = 0;
|
scrollContainer.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollTopNeeded = Math.max(0, elementTop - 20);
|
const scrollTopNeeded = Math.max(
|
||||||
scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'smooth' });
|
0,
|
||||||
|
guideStep === 3 ? elementTop - tooltipHeight - gap - 32 : elementTop - 20,
|
||||||
|
);
|
||||||
|
scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'auto' });
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!targetRef.current) return;
|
if (!targetRef.current) return;
|
||||||
const newElementRect = targetRef.current.getBoundingClientRect();
|
const newElementRect = targetRef.current.getBoundingClientRect();
|
||||||
const newContainerRect = scrollContainer.getBoundingClientRect();
|
const hostRect = overlayHost.getBoundingClientRect();
|
||||||
|
const visibleContainerRect = scrollContainer.getBoundingClientRect();
|
||||||
|
const visibleTop = visibleContainerRect.top - hostRect.top;
|
||||||
|
const visibleBottom = visibleTop + visibleContainerRect.height;
|
||||||
|
|
||||||
const spotlightLeft = newElementRect.left - newContainerRect.left;
|
const spotlightLeft = newElementRect.left - hostRect.left;
|
||||||
const spotlightTop = newElementRect.top - newContainerRect.top;
|
const spotlightTop = newElementRect.top - hostRect.top;
|
||||||
|
|
||||||
const tooltipLeft = Math.max(16, Math.min(
|
const tooltipLeft = Math.max(16, Math.min(
|
||||||
(newElementRect.left + newElementRect.right - tooltipWidth) / 2 - newContainerRect.left,
|
(newElementRect.left + newElementRect.right - tooltipWidth) / 2 - hostRect.left,
|
||||||
containerRect.width - tooltipWidth - 16
|
hostRect.width - tooltipWidth - 16
|
||||||
));
|
));
|
||||||
const tooltipTop = spotlightTop + elementHeight + gap;
|
let tooltipTop = spotlightTop + elementHeight + gap;
|
||||||
|
let side: 'bottom' | 'top' = 'bottom';
|
||||||
|
if (guideStep === 3) {
|
||||||
|
tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap);
|
||||||
|
side = 'top';
|
||||||
|
}
|
||||||
|
if (tooltipTop + tooltipHeight > visibleBottom) {
|
||||||
|
tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap);
|
||||||
|
side = 'top';
|
||||||
|
}
|
||||||
|
|
||||||
setSpotlightRect({
|
setSpotlightRect({
|
||||||
left: spotlightLeft,
|
left: spotlightLeft,
|
||||||
@@ -302,7 +322,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
height: elementHeight
|
height: elementHeight
|
||||||
});
|
});
|
||||||
setTooltipPos({ left: tooltipLeft, top: tooltipTop });
|
setTooltipPos({ left: tooltipLeft, top: tooltipTop });
|
||||||
setTooltipSide('bottom');
|
setTooltipSide(side);
|
||||||
});
|
});
|
||||||
|
|
||||||
prevGuideStepRef.current = guideStep;
|
prevGuideStepRef.current = guideStep;
|
||||||
@@ -495,7 +515,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Six yao rows */}
|
{/* Six yao rows */}
|
||||||
<div className="flex flex-col gap-2.5">
|
<div ref={yaoRowsRef} className="flex flex-col gap-2.5">
|
||||||
{[5, 4, 3, 2, 1, 0].map((index) => {
|
{[5, 4, 3, 2, 1, 0].map((index) => {
|
||||||
const result = yaoResults[index];
|
const result = yaoResults[index];
|
||||||
const isBeingShaken = isShaking && currentShakingYao === index;
|
const isBeingShaken = isShaking && currentShakingYao === index;
|
||||||
@@ -597,11 +617,11 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
{guideOpen && guide && spotlightRect && (
|
{guideOpen && guide && spotlightRect && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/70 md:block hidden"
|
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
|
||||||
onClick={() => closeGuide()}
|
onClick={() => closeGuide()}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-40 bg-black/70 md:hidden"
|
className="absolute inset-0 z-40 xl:hidden"
|
||||||
style={{ top: 0, height: '100vh' }}
|
style={{ top: 0, height: '100vh' }}
|
||||||
onClick={() => closeGuide()}
|
onClick={() => closeGuide()}
|
||||||
/>
|
/>
|
||||||
@@ -631,7 +651,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${
|
className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${
|
||||||
tooltipSide === 'right' ? '-left-1.5 top-6' :
|
tooltipSide === 'right' ? '-left-1.5 top-6' :
|
||||||
tooltipSide === 'left' ? '-right-1.5 top-6' :
|
tooltipSide === 'left' ? '-right-1.5 top-6' :
|
||||||
tooltipSide === 'top' ? '-bottom-1.5 left-6' :
|
tooltipSide === 'top' ? '-bottom-1.5 left-1/2 -translate-x-1/2' :
|
||||||
'-top-1.5 left-6'
|
'-top-1.5 left-6'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
const coinsAreaRef = useRef<HTMLDivElement>(null);
|
const coinsAreaRef = useRef<HTMLDivElement>(null);
|
||||||
const timePanelRef = useRef<HTMLElement>(null);
|
const timePanelRef = useRef<HTMLElement>(null);
|
||||||
const yaoPanelRef = useRef<HTMLElement>(null);
|
const yaoPanelRef = useRef<HTMLElement>(null);
|
||||||
|
const yaoRowsRef = useRef<HTMLDivElement>(null);
|
||||||
const submitBtnRef = useRef<HTMLButtonElement>(null);
|
const submitBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
|
const [spotlightRect, setSpotlightRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
|
||||||
@@ -269,12 +270,17 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetRef = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef][guideStep];
|
const mobileGuideTargets = [coinsAreaRef, timePanelRef, yaoRowsRef, submitBtnRef];
|
||||||
|
const desktopGuideTargets = [coinsAreaRef, timePanelRef, yaoPanelRef, submitBtnRef];
|
||||||
|
const targetRef = (isMobile ? mobileGuideTargets : desktopGuideTargets)[guideStep];
|
||||||
if (!targetRef?.current) return;
|
if (!targetRef?.current) return;
|
||||||
|
|
||||||
const tooltipWidth = 320;
|
const tooltipWidth = 320;
|
||||||
const tooltipHeight = 180;
|
const tooltipHeight = isMobile ? 220 : 180;
|
||||||
const gap = 16;
|
const gap = isMobile ? 24 : 16;
|
||||||
|
|
||||||
|
const overlayHost = scrollContainerRef.current;
|
||||||
|
if (!overlayHost) return;
|
||||||
|
|
||||||
// Get scroll container - it's the main element inside AppShell
|
// Get scroll container - it's the main element inside AppShell
|
||||||
const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null;
|
const scrollContainer = scrollContainerRef.current?.parentElement?.parentElement as HTMLElement | null;
|
||||||
@@ -284,12 +290,10 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
|
|
||||||
// ===== MOBILE: Absolute positioning relative to scroll container =====
|
// ===== MOBILE: Absolute positioning relative to scroll container =====
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
// Calculate element's offset relative to scroll container
|
|
||||||
const containerRect = scrollContainer.getBoundingClientRect();
|
|
||||||
const elementRect = targetRef.current.getBoundingClientRect();
|
const elementRect = targetRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
// Element position relative to scroll container (accounts for current scroll)
|
// Element position relative to scroll container (accounts for current scroll)
|
||||||
const elementLeft = elementRect.left - containerRect.left;
|
const containerRect = scrollContainer.getBoundingClientRect();
|
||||||
const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop;
|
const elementTop = elementRect.top - containerRect.top + scrollContainer.scrollTop;
|
||||||
const elementWidth = elementRect.width;
|
const elementWidth = elementRect.width;
|
||||||
const elementHeight = elementRect.height;
|
const elementHeight = elementRect.height;
|
||||||
@@ -299,30 +303,42 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
scrollContainer.scrollTop = 0;
|
scrollContainer.scrollTop = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate where we need to scroll to make both spotlight and tooltip visible
|
// Calculate where we need to scroll to make the spotlight visible.
|
||||||
// Spotlight should be at top portion, tooltip below it
|
const scrollTopNeeded = Math.max(
|
||||||
const totalHeight = elementHeight + gap + tooltipHeight;
|
0,
|
||||||
const scrollTopNeeded = Math.max(0, elementTop - 20); // 20px margin above spotlight
|
guideStep === 3 ? elementTop - tooltipHeight - gap - 32 : elementTop - 20,
|
||||||
|
);
|
||||||
|
|
||||||
// Smooth scroll to position
|
scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'auto' });
|
||||||
scrollContainer.scrollTo({ top: scrollTopNeeded, behavior: 'smooth' });
|
|
||||||
|
|
||||||
// Use requestAnimationFrame to ensure scroll has started before calculating final position
|
// Use requestAnimationFrame to calculate after the scroll position has updated.
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (!targetRef.current) return;
|
||||||
// Recalculate element position after scroll setup
|
// Recalculate element position after scroll setup
|
||||||
const newElementRect = targetRef.current!.getBoundingClientRect();
|
const newElementRect = targetRef.current.getBoundingClientRect();
|
||||||
const newContainerRect = scrollContainer.getBoundingClientRect();
|
const hostRect = overlayHost.getBoundingClientRect();
|
||||||
|
const visibleContainerRect = scrollContainer.getBoundingClientRect();
|
||||||
|
const visibleTop = visibleContainerRect.top - hostRect.top;
|
||||||
|
const visibleBottom = visibleTop + visibleContainerRect.height;
|
||||||
|
|
||||||
// Position relative to container (for absolute positioning)
|
// Position relative to this component because the mobile overlay is absolute.
|
||||||
const spotlightLeft = newElementRect.left - newContainerRect.left;
|
const spotlightLeft = newElementRect.left - hostRect.left;
|
||||||
const spotlightTop = newElementRect.top - newContainerRect.top;
|
const spotlightTop = newElementRect.top - hostRect.top;
|
||||||
|
|
||||||
// Tooltip goes below the element
|
|
||||||
const tooltipLeft = Math.max(16, Math.min(
|
const tooltipLeft = Math.max(16, Math.min(
|
||||||
(newElementRect.left + newElementRect.right - tooltipWidth) / 2 - newContainerRect.left,
|
(newElementRect.left + newElementRect.right - tooltipWidth) / 2 - hostRect.left,
|
||||||
containerRect.width - tooltipWidth - 16
|
hostRect.width - tooltipWidth - 16
|
||||||
));
|
));
|
||||||
const tooltipTop = spotlightTop + elementHeight + gap;
|
let tooltipTop = spotlightTop + elementHeight + gap;
|
||||||
|
let side: 'bottom' | 'top' = 'bottom';
|
||||||
|
if (guideStep === 3) {
|
||||||
|
tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap);
|
||||||
|
side = 'top';
|
||||||
|
}
|
||||||
|
if (tooltipTop + tooltipHeight > visibleBottom) {
|
||||||
|
tooltipTop = Math.max(visibleTop + 16, spotlightTop - tooltipHeight - gap);
|
||||||
|
side = 'top';
|
||||||
|
}
|
||||||
|
|
||||||
setSpotlightRect({
|
setSpotlightRect({
|
||||||
left: spotlightLeft,
|
left: spotlightLeft,
|
||||||
@@ -331,7 +347,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
height: elementHeight
|
height: elementHeight
|
||||||
});
|
});
|
||||||
setTooltipPos({ left: tooltipLeft, top: tooltipTop });
|
setTooltipPos({ left: tooltipLeft, top: tooltipTop });
|
||||||
setTooltipSide('bottom');
|
setTooltipSide(side);
|
||||||
});
|
});
|
||||||
|
|
||||||
prevGuideStepRef.current = guideStep;
|
prevGuideStepRef.current = guideStep;
|
||||||
@@ -513,7 +529,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
<span className="text-[13px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</span>
|
<span className="text-[13px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2.5">
|
<div ref={yaoRowsRef} className="flex flex-col gap-2.5">
|
||||||
{[5, 4, 3, 2, 1, 0].map((index) => {
|
{[5, 4, 3, 2, 1, 0].map((index) => {
|
||||||
const result = yaoResults[index];
|
const result = yaoResults[index];
|
||||||
// Only show "active" highlight when not in editing mode
|
// Only show "active" highlight when not in editing mode
|
||||||
@@ -598,12 +614,12 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
<>
|
<>
|
||||||
{/* Dark overlay - fixed for desktop, covers viewport */}
|
{/* Dark overlay - fixed for desktop, covers viewport */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-black/70 md:block hidden"
|
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
|
||||||
onClick={() => closeGuide()}
|
onClick={() => closeGuide()}
|
||||||
/>
|
/>
|
||||||
{/* Mobile dark overlay - positioned within scroll container */}
|
{/* Mobile dark overlay - positioned within scroll container */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-40 bg-black/70 md:hidden"
|
className="absolute inset-0 z-40 xl:hidden"
|
||||||
style={{ top: 0, height: '100vh' }}
|
style={{ top: 0, height: '100vh' }}
|
||||||
onClick={() => closeGuide()}
|
onClick={() => closeGuide()}
|
||||||
/>
|
/>
|
||||||
@@ -636,7 +652,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${
|
className={`absolute h-3 w-3 rotate-45 bg-slate-950 ${
|
||||||
tooltipSide === 'right' ? '-left-1.5 top-6' :
|
tooltipSide === 'right' ? '-left-1.5 top-6' :
|
||||||
tooltipSide === 'left' ? '-right-1.5 top-6' :
|
tooltipSide === 'left' ? '-right-1.5 top-6' :
|
||||||
tooltipSide === 'top' ? '-bottom-1.5 left-6' :
|
tooltipSide === 'top' ? '-bottom-1.5 left-1/2 -translate-x-1/2' :
|
||||||
'-top-1.5 left-6'
|
'-top-1.5 left-6'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+14
-1
@@ -89,6 +89,8 @@ export function isTokenExpired(): boolean {
|
|||||||
return auth.expires_at - 60_000 < Date.now();
|
return auth.expires_at - 60_000 < Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let refreshPromise: Promise<AuthData> | null = null;
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
function toAuthData(response: SessionResponse): AuthData {
|
function toAuthData(response: SessionResponse): AuthData {
|
||||||
@@ -144,7 +146,7 @@ export async function loginWithEmail(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(): Promise<AuthData> {
|
async function doRefreshAccessToken(): Promise<AuthData> {
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
if (!auth?.refresh_token) {
|
if (!auth?.refresh_token) {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
@@ -165,6 +167,17 @@ export async function refreshAccessToken(): Promise<AuthData> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(): Promise<AuthData> {
|
||||||
|
if (refreshPromise) return refreshPromise;
|
||||||
|
|
||||||
|
refreshPromise = doRefreshAccessToken();
|
||||||
|
try {
|
||||||
|
return await refreshPromise;
|
||||||
|
} finally {
|
||||||
|
refreshPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user