inital commit
This commit is contained in:
commit
78c2abe791
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/.next/
|
7
lib/utils.ts
Normal file
7
lib/utils.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// lib/utils.ts
|
||||
import clsx, { ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
4541
package-lock.json
generated
Normal file
4541
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "timerweb",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.2.11",
|
||||
"next": "12.3.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"tailwind-merge": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.7.18",
|
||||
"@types/react": "18.0.20",
|
||||
"@types/react-dom": "18.0.6",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "8.23.1",
|
||||
"eslint-config-next": "12.3.1",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "4.8.3"
|
||||
}
|
||||
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
158
public/global.css
Normal file
158
public/global.css
Normal file
@ -0,0 +1,158 @@
|
||||
/* global */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css");
|
||||
html {
|
||||
overflow: hidden;
|
||||
font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* util */
|
||||
|
||||
div.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
label.error {
|
||||
margin-left: 10px;
|
||||
color: red;
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* main page */
|
||||
|
||||
|
||||
div.main-container {
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
|
||||
}
|
||||
|
||||
div.content-container {
|
||||
margin-left: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
div.content-container > iframe {
|
||||
border: 2px solid gray;
|
||||
border: none;
|
||||
border-bottom: 1px solid black;
|
||||
height: 85px;
|
||||
}
|
||||
|
||||
form.embed-config-form li {
|
||||
margin: 5px auto;
|
||||
}
|
||||
|
||||
form.embed-config-form input {
|
||||
width: 150px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
form.embed-config-form input:only-of-type:not([type=color]) {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
form.embed-config-form input[type=color] {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
form.embed-config-form label {
|
||||
width: 100px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
div.clipboard-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin: 20px auto;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
div.clipboard-bar > div {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
border: 2px solid black;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
div.clipboard-bar div.icon {
|
||||
padding: 5px;
|
||||
border-right: 1px solid black;
|
||||
background-color: gray;
|
||||
}
|
||||
|
||||
div.clipboard-bar div.icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
div.clipboard-bar span.text {
|
||||
padding: 5px;
|
||||
white-space: nowrap;
|
||||
overflow-x: scroll;
|
||||
width: 50vw;
|
||||
font-size: 0.8rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
div.clipboard-bar span.text::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Digit style */
|
||||
|
||||
div.timer-container {
|
||||
max-width: 380px;
|
||||
padding: 10px;
|
||||
margin: 10px auto;
|
||||
/* 보더 대신 그림자로 대체 */
|
||||
box-shadow: 0 0 1px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 10px;
|
||||
|
||||
}
|
||||
|
||||
div.timer-container > div.title {
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.timer-container > div.timer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: first baseline;
|
||||
font-family: 'Share Tech Mono';
|
||||
font-size: 30px;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
div.digit {
|
||||
transition: opacity, color 500ms;
|
||||
margin: 0 0.5rem;
|
||||
width: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.digit:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
4
public/vercel.svg
Normal file
4
public/vercel.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
36
src/components/ClipCopybar.tsx
Normal file
36
src/components/ClipCopybar.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import ClipboardIcon from './icons/ClipboardIcon';
|
||||
|
||||
function copyContent(text: string) {
|
||||
return new Promise<string>((res, rej) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => res('복사 성공!'))
|
||||
.catch(err => rej(`복사 실패: ${err}`))
|
||||
})
|
||||
}
|
||||
|
||||
const ClipCopybar: React.FC<{ text: string, icon?: JSX.Element }> = ({ text, icon = <ClipboardIcon /> }) => {
|
||||
const [clickResult, setClickResult] = React.useState('');
|
||||
const handleCopyClick = () => {
|
||||
copyContent(text)
|
||||
.then(res => setClickResult(res))
|
||||
.catch(err => setClickResult(err))
|
||||
.then(() => setTimeout(() => setClickResult(''), 1000))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<div className='icon' onClick={handleCopyClick}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className='text'>
|
||||
<a href={text}>{text}</a>
|
||||
</span>
|
||||
</div>
|
||||
{clickResult}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClipCopybar;
|
24
src/components/ClipboardCopybar.tsx
Normal file
24
src/components/ClipboardCopybar.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import ClipCopybar from './ClipCopybar';
|
||||
import LinkIcon from './icons/LinkIcon';
|
||||
import GithubIcon from './icons/GithubIcon';
|
||||
|
||||
export interface ClipboardCopybarProps {
|
||||
time: string
|
||||
title: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const ClipboardCopybar: React.FC<ClipboardCopybarProps> = ({ time, title, color }) => {
|
||||
const link = `https://timer.runa.pw/embed/?date=${time}&title=${title}&color=${color}`;
|
||||
const text = `<iframe src='${link}' style='border: none; height: 85px;'></iframe>`;
|
||||
|
||||
return (
|
||||
<div className='clipboard-bar flex gap-3 mt-5 text-sm w-min'>
|
||||
<ClipCopybar text={text} />
|
||||
<ClipCopybar text={link} icon={<LinkIcon />} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ClipboardCopybar;
|
32
src/components/ColorPickInput.tsx
Normal file
32
src/components/ColorPickInput.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
|
||||
const ColorPickInput: React.FC<{
|
||||
color: string,
|
||||
setColor: React.Dispatch<React.SetStateAction<string>>
|
||||
}> = ({ color, setColor }) => {
|
||||
const [input, setInput] = React.useState<JSX.Element>(<></>);
|
||||
|
||||
React.useEffect(() => {
|
||||
setInput(<input type='color' value={color} onChange={handleColorChange} className='text-black border border-gray-300 rounded h-10 w-10' />);
|
||||
}, [color]);
|
||||
|
||||
const handleColorChange: React.ChangeEventHandler<HTMLInputElement> = ({ target: { value } }) => {
|
||||
setColor(value);
|
||||
const elem = document.querySelector('form.embed-config-form > ul > li > label.error');
|
||||
if (elem) elem.innerHTML = /^#?[a-f|A-F|0-9]{6}$/.test(value) ? '' : 'invalid format!';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<label>타이머 색 </label>
|
||||
<br />
|
||||
<div className='flex items-center gap-2'>
|
||||
<input type='text' value={color} onChange={handleColorChange} className='text-black border border-gray-300 rounded px-3 py-2' />
|
||||
{input}
|
||||
</div>
|
||||
<label className='error'></label>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColorPickInput;
|
37
src/components/Digit.tsx
Normal file
37
src/components/Digit.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import useInterval from '../hooks/useInterval';
|
||||
|
||||
export interface DigitProps extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
|
||||
getter: () => string
|
||||
ignoreBlink?: boolean
|
||||
}
|
||||
|
||||
const Digit: React.FC<DigitProps> = ({ getter, ignoreBlink = false, ...props }) => {
|
||||
const [time, setTime] = React.useState<string>('00');
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useInterval(() => {
|
||||
const remainingTime = getter();
|
||||
if (time !== remainingTime) {
|
||||
setTime(remainingTime);
|
||||
|
||||
if (ignoreBlink) {
|
||||
if (ref.current?.className.includes('blink')) ref.current.className = 'digit'
|
||||
}
|
||||
else if (ref.current) {
|
||||
ref.current.className = 'digit blink';
|
||||
setTimeout(() => {
|
||||
if (ref.current) ref.current.className = 'digit'
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
}, 10);
|
||||
|
||||
return (
|
||||
<div ref={ref} className='digit blink' {...props}>
|
||||
{time}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Digit;
|
40
src/components/icons/ClipboardIcon.tsx
Normal file
40
src/components/icons/ClipboardIcon.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
const ClipboardIcon: React.FC = () => (
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
xmlSpace="preserve"
|
||||
x="0px" y="0px"
|
||||
viewBox="0 0 64 64"
|
||||
enableBackground="new 0 0 64 64"
|
||||
>
|
||||
<g id="Text-files">
|
||||
<path d="M53.9791489,9.1429005H50.010849c-0.0826988,0-0.1562004,0.0283995-0.2331009,0.0469999V5.0228
|
||||
C49.7777481,2.253,47.4731483,0,44.6398468,0h-34.422596C7.3839517,0,5.0793519,2.253,5.0793519,5.0228v46.8432999
|
||||
c0,2.7697983,2.3045998,5.0228004,5.1378999,5.0228004h6.0367002v2.2678986C16.253952,61.8274002,18.4702511,64,21.1954517,64
|
||||
h32.783699c2.7252007,0,4.9414978-2.1725998,4.9414978-4.8432007V13.9861002
|
||||
C58.9206467,11.3155003,56.7043495,9.1429005,53.9791489,9.1429005z M7.1110516,51.8661003V5.0228
|
||||
c0-1.6487999,1.3938999-2.9909999,3.1062002-2.9909999h34.422596c1.7123032,0,3.1062012,1.3422,3.1062012,2.9909999v46.8432999
|
||||
c0,1.6487999-1.393898,2.9911003-3.1062012,2.9911003h-34.422596C8.5049515,54.8572006,7.1110516,53.5149002,7.1110516,51.8661003z
|
||||
M56.8888474,59.1567993c0,1.550602-1.3055,2.8115005-2.9096985,2.8115005h-32.783699
|
||||
c-1.6042004,0-2.9097996-1.2608986-2.9097996-2.8115005v-2.2678986h26.3541946
|
||||
c2.8333015,0,5.1379013-2.2530022,5.1379013-5.0228004V11.1275997c0.0769005,0.0186005,0.1504021,0.0469999,0.2331009,0.0469999
|
||||
h3.9682999c1.6041985,0,2.9096985,1.2609005,2.9096985,2.8115005V59.1567993z"/>
|
||||
<path d="M38.6031494,13.2063999H16.253952c-0.5615005,0-1.0159006,0.4542999-1.0159006,1.0158005
|
||||
c0,0.5615997,0.4544001,1.0158997,1.0159006,1.0158997h22.3491974c0.5615005,0,1.0158997-0.4542999,1.0158997-1.0158997
|
||||
C39.6190491,13.6606998,39.16465,13.2063999,38.6031494,13.2063999z"/>
|
||||
<path d="M38.6031494,21.3334007H16.253952c-0.5615005,0-1.0159006,0.4542999-1.0159006,1.0157986
|
||||
c0,0.5615005,0.4544001,1.0159016,1.0159006,1.0159016h22.3491974c0.5615005,0,1.0158997-0.454401,1.0158997-1.0159016
|
||||
C39.6190491,21.7877007,39.16465,21.3334007,38.6031494,21.3334007z"/>
|
||||
<path d="M38.6031494,29.4603004H16.253952c-0.5615005,0-1.0159006,0.4543991-1.0159006,1.0158997
|
||||
s0.4544001,1.0158997,1.0159006,1.0158997h22.3491974c0.5615005,0,1.0158997-0.4543991,1.0158997-1.0158997
|
||||
S39.16465,29.4603004,38.6031494,29.4603004z"/>
|
||||
<path d="M28.4444485,37.5872993H16.253952c-0.5615005,0-1.0159006,0.4543991-1.0159006,1.0158997
|
||||
s0.4544001,1.0158997,1.0159006,1.0158997h12.1904964c0.5615025,0,1.0158005-0.4543991,1.0158005-1.0158997
|
||||
S29.0059509,37.5872993,28.4444485,37.5872993z"/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default ClipboardIcon;
|
9
src/components/icons/GithubIcon.tsx
Normal file
9
src/components/icons/GithubIcon.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
const GithubIcon: React.FC = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default GithubIcon;
|
7
src/components/icons/LinkIcon.tsx
Normal file
7
src/components/icons/LinkIcon.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
const LinkIcon: React.FC = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M8,12a1,1,0,0,0,1,1h6a1,1,0,0,0,0-2H9A1,1,0,0,0,8,12Zm2,3H7A3,3,0,0,1,7,9h3a1,1,0,0,0,0-2H7A5,5,0,0,0,7,17h3a1,1,0,0,0,0-2Zm7-8H14a1,1,0,0,0,0,2h3a3,3,0,0,1,0,6H14a1,1,0,0,0,0,2h3A5,5,0,0,0,17,7Z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default LinkIcon;
|
17
src/hooks/useInterval.ts
Normal file
17
src/hooks/useInterval.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
function useInterval(callback: Function, delay: number) {
|
||||
const savedCallback = React.useRef<Function>();
|
||||
|
||||
React.useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (delay === null) return;
|
||||
const id = setInterval(() => savedCallback.current?.call(null), delay);
|
||||
return () => clearInterval(id);
|
||||
}, [delay]);
|
||||
}
|
||||
|
||||
export default useInterval;
|
15
src/pages/_app.tsx
Normal file
15
src/pages/_app.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type { AppProps } from 'next/app'
|
||||
import Head from 'next/head';
|
||||
import 'public/global.css'
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <>
|
||||
<Head>
|
||||
<title>카운트다운</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
}
|
||||
|
||||
export default MyApp
|
18
src/pages/_document.tsx
Normal file
18
src/pages/_document.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html>
|
||||
<Head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Orbitron&display=swap" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Share%20Tech%20Mono&display=swap" rel="stylesheet" />
|
||||
</Head>
|
||||
<body className='font-orbitron justify-center flex bg-gray-300'>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
66
src/pages/embed.tsx
Normal file
66
src/pages/embed.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react'
|
||||
import Digit from '../components/Digit';
|
||||
import { getRemainingTime } from '../utils/getRemainingTime';
|
||||
import useInterval from '../hooks/useInterval';
|
||||
|
||||
const Title: React.FC<{ format: string, date: string }> = ({ format, date }) => {
|
||||
const [title, setTitle] = React.useState('');
|
||||
|
||||
useInterval(() => {
|
||||
setTitle(format.replace(/(?:^\$|[^\\]\$)(D|H|M|S)/gi, (_, p: string, index: number, string: string) => {
|
||||
const obj: Record<string, number> = {
|
||||
d: getRemainingTime(date, 'day', true),
|
||||
D: getRemainingTime(date, 'day', true),
|
||||
H: getRemainingTime(date, 'hour', true),
|
||||
h: getRemainingTime(date, 'hour'),
|
||||
M: getRemainingTime(date, 'minute', true),
|
||||
m: getRemainingTime(date, 'minute'),
|
||||
S: getRemainingTime(date, 'second', true),
|
||||
s: getRemainingTime(date, 'second')
|
||||
}
|
||||
return (index === 0 ? '' : string[index]) + (obj[p] ?? '').toString();
|
||||
}))
|
||||
}, 10);
|
||||
if (title === '') return null;
|
||||
return <div className='title'>{title}</div>
|
||||
}
|
||||
|
||||
const TimerEmbed: React.FC = () => {
|
||||
const { query } = useRouter();
|
||||
|
||||
const date = query.date?.toString() || "";
|
||||
const title = query.title?.toString() || "";
|
||||
const color = query.color?.toString().replaceAll('#', '') || '000000';
|
||||
|
||||
return (
|
||||
<div className='timer-container bg-white'>
|
||||
<Title format={title} date={date} />
|
||||
<div className='timer' style={{ color: '#' + color }}>
|
||||
{date && !/\d{8},\d{6}/.test(date) ? (
|
||||
<span className='text-red-500'>날짜 형식 불일치</span>
|
||||
) : (
|
||||
<>
|
||||
<Digit getter={() => getRemainingTime(date, 'day').toString().padStart(2, '0')} />
|
||||
:
|
||||
<Digit getter={() => getRemainingTime(date, 'hour').toString().padStart(2, '0')} />
|
||||
:
|
||||
<Digit getter={() => getRemainingTime(date, 'minute').toString().padStart(2, '0')} />
|
||||
:
|
||||
<Digit getter={() => getRemainingTime(date, 'second').toString().padStart(2, '0')} style={{ marginRight: 0 }} />
|
||||
<div className='flex transform -translate-x-1 items-baseline'>
|
||||
.
|
||||
<Digit
|
||||
ignoreBlink
|
||||
getter={() => Math.round(getRemainingTime(date, 'milisecond') / 10).toString().slice(0, 2).padStart(2, '0')}
|
||||
style={{ textAlign: 'left', fontSize: '0.5em', margin: 0, width: '20px' }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimerEmbed;
|
53
src/pages/index.tsx
Normal file
53
src/pages/index.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import ClipboardCopybar from '../components/ClipboardCopybar';
|
||||
import ColorPickInput from '../components/ColorPickInput';
|
||||
|
||||
const MainPage: React.FC = () => {
|
||||
const [time, setTime] = React.useState('20240703,082000');
|
||||
const [color, setColor] = React.useState('000000');
|
||||
const [title, setTitle] = React.useState('기말까지');
|
||||
|
||||
return (
|
||||
|
||||
<div className='flex flex-col items-center justify-center text-black bg-gray-900 p-5 gap-5 h-screen w-screen'>
|
||||
<div className='w-1/2 p-5 rounded-lg shadow bg-gray-50'>
|
||||
<h2 className='text-2xl mb-3 font-bold'>카운트다운 생성기</h2>
|
||||
<form className='mb-5'>
|
||||
<ul>
|
||||
<li className='mb-3'>
|
||||
<label className='text-lg'>목표시각</label>
|
||||
<input
|
||||
className='text-black border border-gray-300 rounded px-3 py-2 w-full'
|
||||
type='datetime-local'
|
||||
defaultValue={'2024-07-03T08:20'}
|
||||
onChange={ev =>
|
||||
setTime(ev.target.value.replaceAll(
|
||||
/[(\-|:)|(T)]/g,
|
||||
(match) => match === 'T' ? ',' : ''
|
||||
) + '00')
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
<li className='mb-3'>
|
||||
<ColorPickInput color={color} setColor={setColor} />
|
||||
</li>
|
||||
<li className='mb-3'>
|
||||
<label className='text-lg'>타이틀</label>
|
||||
<input
|
||||
type='text'
|
||||
defaultValue='기말까지'
|
||||
onChange={ev => setTitle(ev.target.value)}
|
||||
className='text-black border border-gray-300 rounded px-3 py-2 w-full'
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
<iframe src={`/embed/?date=${time}&title=${encodeURIComponent(title)}&color=${encodeURIComponent(color)}`} style={{ border: 'none', height: '95px', backgroundColor: "#D1D5DB", justifySelf: 'center' }} className='w-full' />
|
||||
<div className='w-full h-px bg-gray-50 mt-8 flex items-center justify-center' />
|
||||
<ClipboardCopybar time={time} title={title} color={color} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MainPage;
|
21
src/utils/getRemainingTime.ts
Normal file
21
src/utils/getRemainingTime.ts
Normal file
@ -0,0 +1,21 @@
|
||||
const parseDate = (date: string) => {
|
||||
return new Date(+date.slice(0, 4), +date.slice(4, 6) - 1, +date.slice(6, 8), +date.slice(9, 11), +date.slice(11, 13), +date.slice(13, 15));
|
||||
};
|
||||
|
||||
type timeType = 'day' | 'hour' | 'minute' | 'second' | 'milisecond';
|
||||
const cal: Record<timeType, (n: number, all: boolean) => number> = {
|
||||
day: n => n / (1000 * 60 * 60 * 24),
|
||||
hour: (n, a) => n / (1000 * 60 * 60) % (a ? Infinity : 24),
|
||||
minute: (n, a) => n / (1000 * 60) % (a ? Infinity : 60),
|
||||
second: (n, a) => n / 1000 % (a ? Infinity : 60),
|
||||
milisecond: (n, a) => n % (a ? Infinity : 1000),
|
||||
};
|
||||
|
||||
export const getRemainingTime = (date: string, type: timeType, all = false) => {
|
||||
const currentDate = new Date();
|
||||
const targetDate = parseDate(date);
|
||||
const timeDifference = targetDate.getTime() - currentDate.getTime();
|
||||
let result = Math.max(Math.floor(cal[type](timeDifference, all)), 0);
|
||||
//print out the result
|
||||
return Math.max(Math.floor(cal[type](timeDifference, all)), 0);
|
||||
};
|
15
tailwind.config.js
Normal file
15
tailwind.config.js
Normal file
@ -0,0 +1,15 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
|
||||
// Or if using `src` directory:
|
||||
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user