Adapt layout for dynamic island
Adjust top margin based on platform to accommodate the dynamic island on iOS devices.
This commit is contained in:
@@ -5,6 +5,7 @@ import { useAuth } from '@/contexts/auth';
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
|
import { isIOSPlatform } from '@/utils/platform';
|
||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -14,6 +15,9 @@ const Header: React.FC = () => {
|
|||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
// iOS 기기에서는 상단 안전 영역을 위한 클래스 적용
|
||||||
|
const safeAreaClass = isIOSPlatform() ? 'needs-top-safe-area' : '';
|
||||||
|
|
||||||
// 이미지 프리로딩 처리
|
// 이미지 프리로딩 처리
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,16 +32,27 @@ const Header: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <header className="py-4">
|
return (
|
||||||
|
<header className={`py-4 ${safeAreaClass}`}>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Avatar className="h-12 w-12 mr-3">
|
<Avatar className="h-12 w-12 mr-3">
|
||||||
{!imageLoaded && !imageError ? <div className="h-full w-full flex items-center justify-center">
|
{!imageLoaded && !imageError ? (
|
||||||
|
<div className="h-full w-full flex items-center justify-center">
|
||||||
<Skeleton className="h-full w-full rounded-full" />
|
<Skeleton className="h-full w-full rounded-full" />
|
||||||
</div> : <>
|
</div>
|
||||||
<AvatarImage src="/zellyy.png" alt="Zellyy" className={imageLoaded ? 'opacity-100' : 'opacity-0'} onLoad={() => setImageLoaded(true)} onError={() => setImageError(true)} />
|
) : (
|
||||||
|
<>
|
||||||
|
<AvatarImage
|
||||||
|
src="/zellyy.png"
|
||||||
|
alt="Zellyy"
|
||||||
|
className={imageLoaded ? 'opacity-100' : 'opacity-0'}
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
/>
|
||||||
{(imageError || !imageLoaded) && <AvatarFallback delayMs={100}>ZY</AvatarFallback>}
|
{(imageError || !imageLoaded) && <AvatarFallback delayMs={100}>ZY</AvatarFallback>}
|
||||||
</>}
|
</>
|
||||||
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-bold neuro-text text-xl">
|
<h1 className="font-bold neuro-text text-xl">
|
||||||
@@ -50,7 +65,8 @@ const Header: React.FC = () => {
|
|||||||
<Bell size={20} className="text-gray-600" />
|
<Bell size={20} className="text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>;
|
</header>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
|
|||||||
33
src/components/SafeAreaContainer.tsx
Normal file
33
src/components/SafeAreaContainer.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { isIOSPlatform } from '@/utils/platform';
|
||||||
|
|
||||||
|
interface SafeAreaContainerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
applyTop?: boolean;
|
||||||
|
applyBottom?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전 영역(Safe Area)을 고려한 컨테이너 컴포넌트
|
||||||
|
* iOS의 다이나믹 아일랜드/노치와 하단 홈 인디케이터를 고려하여 패딩을 적용합니다.
|
||||||
|
*/
|
||||||
|
const SafeAreaContainer: React.FC<SafeAreaContainerProps> = ({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
applyTop = true,
|
||||||
|
applyBottom = false
|
||||||
|
}) => {
|
||||||
|
// iOS 플랫폼인 경우에만 안전 영역 패딩 적용
|
||||||
|
const topPadding = applyTop && isIOSPlatform() ? 'pt-safe-area' : '';
|
||||||
|
const bottomPadding = applyBottom && isIOSPlatform() ? 'pb-safe-area' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${topPadding} ${bottomPadding} ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SafeAreaContainer;
|
||||||
246
src/index.css
246
src/index.css
@@ -1,190 +1,84 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
/* Define the glassmorphism effect */
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
/* Define the neumorphism effect */
|
||||||
|
.neuro {
|
||||||
|
background: #E0E5EC;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: -5px -5px 10px #FFFFFF,
|
||||||
|
5px 5px 10px #BABECC;
|
||||||
|
}
|
||||||
|
/* Neumorphism text */
|
||||||
|
.neuro-text {
|
||||||
|
color: #666;
|
||||||
|
text-shadow: -1px -1px 1px #fff, 1px 1px 1px #babebc;
|
||||||
|
}
|
||||||
|
/* Flat neumorphism effect (pressed) */
|
||||||
|
.neuro-flat {
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #E0E5EC;
|
||||||
|
box-shadow: inset -5px -5px 10px #FFFFFF,
|
||||||
|
inset 5px 5px 10px #BABECC;
|
||||||
|
}
|
||||||
|
/* Skeleton styles */
|
||||||
|
.skeleton {
|
||||||
|
animation: skeleton-loading 1.2s linear infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@keyframes skeleton-loading {
|
||||||
:root {
|
0% {
|
||||||
--background: 0 0% 100%;
|
background-color: hsl(200, 20%, 70%);
|
||||||
--foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
|
||||||
--primary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
|
||||||
|
|
||||||
--accent: 210 40% 96.1%;
|
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
|
||||||
--input: 214.3 31.8% 91.4%;
|
|
||||||
--ring: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--radius: 1rem;
|
|
||||||
|
|
||||||
--sidebar-background: 0 0% 98%;
|
|
||||||
|
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
|
||||||
|
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
|
||||||
|
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
|
||||||
|
|
||||||
--sidebar-accent: 240 4.8% 95.9%;
|
|
||||||
|
|
||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
|
||||||
|
|
||||||
--sidebar-border: 220 13% 91%;
|
|
||||||
|
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
100% {
|
||||||
--background: 222.2 84% 4.9%;
|
background-color: hsl(200, 20%, 95%);
|
||||||
--foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
|
||||||
--card-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--popover: 222.2 84% 4.9%;
|
|
||||||
--popover-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--primary: 210 40% 98%;
|
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
|
||||||
--secondary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
|
||||||
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
|
||||||
--input: 217.2 32.6% 17.5%;
|
|
||||||
--ring: 212.7 26.8% 83.9%;
|
|
||||||
--sidebar-background: 240 5.9% 10%;
|
|
||||||
--sidebar-foreground: 240 4.8% 95.9%;
|
|
||||||
--sidebar-primary: 224.3 76.3% 48%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
|
||||||
--sidebar-accent: 240 3.7% 15.9%;
|
|
||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
/* 안전 영역 (Safe Area) 관련 유틸리티 클래스 */
|
||||||
* {
|
:root {
|
||||||
@apply border-border;
|
--safe-area-top: 0px;
|
||||||
}
|
--safe-area-bottom: 0px;
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-neuro-background text-foreground font-inter antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body, #root {
|
|
||||||
@apply h-full overflow-x-hidden;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
.pt-safe-area {
|
||||||
.neuro-flat {
|
padding-top: var(--safe-area-top);
|
||||||
@apply bg-neuro-background shadow-neuro-flat rounded-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neuro-pressed {
|
|
||||||
@apply bg-neuro-background shadow-neuro-pressed rounded-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neuro-convex {
|
|
||||||
@apply bg-neuro-background shadow-neuro-convex rounded-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neuro-text {
|
|
||||||
@apply font-medium tracking-wide;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-transition-enter {
|
|
||||||
@apply animate-fade-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glass-effect {
|
|
||||||
@apply bg-white/10 backdrop-blur-lg border border-white/20 rounded-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neuro-button {
|
|
||||||
@apply neuro-flat px-4 py-3 text-neuro-accent font-medium transition-all duration-200
|
|
||||||
hover:shadow-neuro-convex hover:text-neuro-accent-light active:shadow-neuro-pressed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neuro-card {
|
|
||||||
@apply neuro-flat p-6 transition-all duration-300 hover:shadow-neuro-convex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neuro-input {
|
|
||||||
@apply neuro-pressed px-4 py-3 w-full focus:outline-none focus:ring-2 focus:ring-neuro-accent/30;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일 화면에서의 추가 스타일 */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.neuro-card {
|
|
||||||
@apply w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
@apply p-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모바일에서 팝업과 다이얼로그 스타일 보정 */
|
|
||||||
[role="dialog"] {
|
|
||||||
@apply rounded-xl overflow-hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 다이얼로그 내용에 적용되는 스타일 */
|
|
||||||
.DialogContent,
|
|
||||||
.PopoverContent,
|
|
||||||
.AlertDialogContent,
|
|
||||||
.DrawerContent,
|
|
||||||
.SheetContent {
|
|
||||||
@apply rounded-xl overflow-hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 데스크탑 화면에서의 추가 스타일 */
|
|
||||||
@media (min-width: 769px) {
|
|
||||||
#root {
|
|
||||||
@apply px-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop-container {
|
|
||||||
@apply max-w-md mx-auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.desktop-card {
|
|
||||||
@apply w-full mx-auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-inter {
|
.pb-safe-area {
|
||||||
font-family: 'Inter', sans-serif;
|
padding-bottom: var(--safe-area-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-platform .needs-top-safe-area {
|
||||||
|
padding-top: var(--safe-area-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-platform .needs-bottom-safe-area {
|
||||||
|
padding-bottom: var(--safe-area-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 추가 플랫폼별 스타일 */
|
||||||
|
.ios-platform-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.android-platform-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ios-platform .ios-platform-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.android-platform .android-platform-only {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/main.tsx
15
src/main.tsx
@@ -1,5 +1,14 @@
|
|||||||
import { createRoot } from 'react-dom/client'
|
import React from 'react'
|
||||||
import App from './App.tsx'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import { applySafeAreaInsets } from './utils/safeArea';
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(<App />);
|
// 안전 영역 CSS 변수 초기화
|
||||||
|
applySafeAreaInsets();
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|||||||
61
src/utils/safeArea.ts
Normal file
61
src/utils/safeArea.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
|
||||||
|
import { isAndroidPlatform, isIOSPlatform } from './platform';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기기 플랫폼에 따라 상단 안전 영역(Safe Area) 패딩 값을 반환합니다.
|
||||||
|
* iOS의 경우 다이나믹 아일랜드/노치를 위한 여백을 제공합니다.
|
||||||
|
*/
|
||||||
|
export const getTopSafeAreaPadding = (): string => {
|
||||||
|
if (isIOSPlatform()) {
|
||||||
|
return 'env(safe-area-inset-top, 47px)';
|
||||||
|
}
|
||||||
|
return '0px';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기기 플랫폼에 따라 하단 안전 영역(Safe Area) 패딩 값을 반환합니다.
|
||||||
|
* iOS의 경우 홈 인디케이터를 위한 여백을 제공합니다.
|
||||||
|
*/
|
||||||
|
export const getBottomSafeAreaPadding = (): string => {
|
||||||
|
if (isIOSPlatform()) {
|
||||||
|
return 'env(safe-area-inset-bottom, 34px)';
|
||||||
|
}
|
||||||
|
return '0px';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플랫폼 감지 및 안전 영역 CSS 변수를 문서에 적용합니다.
|
||||||
|
* 이 함수는 앱 초기화시 한 번 호출해야 합니다.
|
||||||
|
*/
|
||||||
|
export const applySafeAreaInsets = (): void => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// 최초 로드 시 안전 영역 값 설정
|
||||||
|
updateSafeAreaVariables();
|
||||||
|
|
||||||
|
// 방향 변경 시 안전 영역 값 다시 계산
|
||||||
|
window.addEventListener('orientationchange', updateSafeAreaVariables);
|
||||||
|
window.addEventListener('resize', updateSafeAreaVariables);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전 영역 CSS 변수를 문서에 업데이트합니다.
|
||||||
|
*/
|
||||||
|
const updateSafeAreaVariables = (): void => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
// 플랫폼에 따른 안전 영역 패딩 계산
|
||||||
|
root.style.setProperty('--safe-area-top', getTopSafeAreaPadding());
|
||||||
|
root.style.setProperty('--safe-area-bottom', getBottomSafeAreaPadding());
|
||||||
|
|
||||||
|
// 디버깅용: 현재 사용 중인 플랫폼 표시
|
||||||
|
if (isIOSPlatform()) {
|
||||||
|
root.classList.add('ios-platform');
|
||||||
|
root.classList.remove('android-platform');
|
||||||
|
} else if (isAndroidPlatform()) {
|
||||||
|
root.classList.add('android-platform');
|
||||||
|
root.classList.remove('ios-platform');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('ios-platform', 'android-platform');
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user