Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
490 changes: 257 additions & 233 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"@tanstack/eslint-plugin-query": "^5.62.9",
"@tanstack/react-query": "^5.62.12",
"axios": "^1.7.9",
"pako": "^2.1.0",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-ga4": "^2.1.0",
Expand All @@ -43,6 +45,7 @@
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.14",
"@types/pako": "^2.0.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
Expand Down
59 changes: 59 additions & 0 deletions src/components/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';

interface LoadingSpinnerProps {
/**
* Tailwind CSS class string for controlling the size (e.g., 'h-6 w-6').
* Defaults to 'h-6 w-6'.
*/
size?: string;
/**
* Tailwind CSS class string for controlling the color (e.g., 'text-blue-500').
* Defaults to 'text-blue-500'.
*/
color?: string;
/**
* Stroke width of the spinner circle.
* Defaults to 4.
*/
strokeWidth?: number;
}

const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = 'h-6 w-6',
color = 'text-blue-500',
strokeWidth = 4, // Add default strokeWidth
}) => {
// Calculate circumference for stroke-dasharray
const radius = 10; // Based on r="10" in the circle element
const circumference = 2 * Math.PI * radius;

return (
<svg
className={`animate-spin ${size} ${color}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
role="status" // Accessibility role
aria-label="Loading" // Accessibility label
>
{/* Use a single circle */}
<circle
cx="12"
cy="12"
r={radius} // Use the radius variable
stroke="currentColor"
strokeWidth={strokeWidth} // Use the strokeWidth prop
// These properties create the partial circle effect and make it spin
strokeDasharray={circumference * 0.75 + ' ' + circumference * 0.25} // Example: 75% arc, 25% gap
strokeDashoffset="0" // Starting offset
strokeLinecap="round" // Optional: gives rounded ends to the arc
className="origin-center" // Ensure the circle rotates from its center (important for stroke-dashoffset animation)
style={{ transformBox: 'fill-box', transformOrigin: 'center' }} // Ensure rotation works correctly
>
{/* The animate-spin class on the parent SVG handles the rotation */}
</circle>
</svg>
);
};

export default LoadingSpinner;
32 changes: 32 additions & 0 deletions src/components/ShareModal/ShareModal.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Meta, StoryObj } from '@storybook/react';
import ShareModal from './ShareModal';

const meta: Meta<typeof ShareModal> = {
title: 'components/ShareModal',
component: ShareModal,
tags: ['autodocs'],
};

export default meta;

type Story = StoryObj<typeof ShareModal>;

// When QR code is ready
export const OnQRCodeReady: Story = {
args: {
shareUrl: 'https://www.naver.com',
copyState: false,
isUrlReady: true,
onClick: () => {},
},
};

// When QR code is not ready
export const OnLoadingData: Story = {
args: {
shareUrl: '',
copyState: false,
isUrlReady: false,
onClick: () => {},
},
};
72 changes: 72 additions & 0 deletions src/components/ShareModal/ShareModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { QRCodeSVG } from 'qrcode.react';
import { IoLinkOutline, IoShareOutline } from 'react-icons/io5';
import LoadingSpinner from '../LoadingSpinner';

interface ShareModalProps {
shareUrl: string;
copyState: boolean;
isUrlReady: boolean;
onClick: () => void;
}

export default function ShareModal({
shareUrl,
copyState,
isUrlReady,
onClick,
}: ShareModalProps) {
return (
<div className="flex w-[500px] flex-col items-center justify-center space-y-10 p-[40px]">
<div
className="relative flex size-[290px] items-center justify-center rounded-2xl"
style={{ boxShadow: 'inset 0 4px 8px rgba(0, 0, 0, 0.10)' }}
>
{/* This component appears to tell the user that URL is succefully copied to clipboard. */}
{/* It will disappear after 3 seconds. */}
{copyState && (
<div className="absolute flex size-full rounded-2xl">
<div className="absolute z-10 size-full rounded-2xl bg-neutral-900 opacity-80" />
<div className="absolute z-20 flex size-full flex-col items-center justify-center space-y-4 p-[30px] text-neutral-50">
<IoShareOutline className="size-[120px]" />
<p className="whitespace-nowrap text-center text-[20px]">
링크가 클립보드에 복사됨
</p>
</div>
</div>
)}

{/* QR code is here. */}
{/* If QR code is not prepared because response is not arrived, spinner will be shown. */}
<div className="m-[50px] flex size-full items-center justify-center">
{isUrlReady && (
<QRCodeSVG
value={shareUrl}
bgColor="#f6f5f4"
className="size-full"
/>
)}
{!isUrlReady && (
<LoadingSpinner
strokeWidth={3}
size={'size-24'}
color={'text-neutral-300'}
/>
)}
</div>
</div>

{/* Button that copies URL to the user's clipboard. */}
<button
className="button enabled relative flex h-[64px] w-[360px] items-center px-5"
onClick={() => {
onClick();
}}
>
<p className="absolute left-1/2 -translate-x-1/2 transform text-[28px] font-bold">
공유 링크 복사
</p>
<IoLinkOutline className="ml-auto size-8" />
</button>
</div>
);
}
59 changes: 59 additions & 0 deletions src/hooks/useTableShare.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { createEncodedURL } from '../util/arrayEncoding';
import { useModal } from './useModal';
import ShareModal from '../components/ShareModal/ShareModal';
import { useGetCustomizeTableData } from './query/useGetCustomizeTableData';
import { useEffect, useState } from 'react';

export function useTableShare(tableId: number) {
const { isOpen, openModal, closeModal, ModalWrapper } = useModal();
const [copyState, setCopyState] = useState(false);
const [isUrlReady, setIsUrlReady] = useState(false);
const [shareUrl, setShareUrl] = useState('');
const baseUrl =
import.meta.env.MODE !== 'production'
? undefined
: import.meta.env.VITE_SHARE_BASE_URL;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopyState(true);
} catch (err) {
console.error('Failed to copy: ', err);
}
};
const data = useGetCustomizeTableData(tableId, isOpen);

useEffect(() => {
if (data.data) {
setShareUrl(createEncodedURL(baseUrl, data.data.table));
setIsUrlReady(true);
}
}, [baseUrl, data]);

useEffect(() => {
if (copyState) {
setTimeout(() => {
setCopyState(false);
}, 3000);
}
});

const TableShareModal = () =>
isOpen ? (
<ModalWrapper closeButtonColor="text-neutral-900">
<ShareModal
shareUrl={shareUrl}
copyState={copyState}
isUrlReady={isUrlReady}
onClick={() => handleCopy()}
/>
</ModalWrapper>
) : null;

return {
isShareOpen: isOpen,
openShareModal: openModal,
closeShareModal: closeModal,
TableShareModal,
};
}
4 changes: 2 additions & 2 deletions src/mocks/handlers/customize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ export const customizeHandlers = [
boxType: 'TIME_BASED',
time: null,
timePerTeam: 60,
timePerSpeaking: 20,
timePerSpeaking: 33,
speaker: null,
},
{
stance: 'NEUTRAL',
speechType: '자유토론',
boxType: 'TIME_BASED',
time: null,
timePerTeam: 60,
timePerTeam: 35,
timePerSpeaking: null,
speaker: null,
},
Expand Down
Loading
Loading