diff --git a/index.html b/index.html index e54505a..0ebd533 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + MingdaOS diff --git a/src/App.css b/src/App.css index ecaa212..43043d4 100644 --- a/src/App.css +++ b/src/App.css @@ -22,6 +22,8 @@ --menubar-height: 28px; --dock-height: 72px; --titlebar-height: 36px; + --mobile-statusbar-height: 44px; + --mobile-icon-size: 64px; --pixel-border: 2px solid #333333; --pixel-shadow: 4px 4px 0 #000000; diff --git a/src/components/Desktop/Desktop.tsx b/src/components/Desktop/Desktop.tsx index bc69de4..d9be2f1 100644 --- a/src/components/Desktop/Desktop.tsx +++ b/src/components/Desktop/Desktop.tsx @@ -1,17 +1,23 @@ import { useState, useCallback } from 'react'; +import { AnimatePresence } from 'framer-motion'; import MenuBar from '../MenuBar/MenuBar'; import Dock from '../Dock/Dock'; import DesktopIcon from '../DesktopIcon/DesktopIcon'; import Window from '../Window/Window'; import ContextMenu from '../ContextMenu/ContextMenu'; +import MobileStatusBar from '../MobileStatusBar/MobileStatusBar'; +import MobileHomeScreen from '../MobileHomeScreen/MobileHomeScreen'; +import MobileAppView from '../MobileAppView/MobileAppView'; import { useOS } from '../../context/WindowContext'; +import { useMobile } from '../../hooks/useMobile'; import { APPS, WALLPAPERS } from '../../config/apps'; import './Desktop.css'; interface CtxMenu { x: number; y: number } export default function Desktop() { - const { state, openWindow, setWallpaper } = useOS(); + const { state, openWindow, setWallpaper, openMobileApp, closeMobileApp } = useOS(); + const isMobile = useMobile(); const [ctxMenu, setCtxMenu] = useState(null); const handleContextMenu = useCallback((e: React.MouseEvent) => { @@ -28,13 +34,29 @@ export default function Desktop() { openWindow('about'); }, [openWindow]); + const wallpaperStyle = { + background: WALLPAPERS[state.wallpaper], + backgroundSize: WALLPAPERS[state.wallpaper].startsWith('url(') ? 'cover' : undefined, + }; + + if (isMobile) { + return ( +
+ + + {state.activeAppId == null + ? + : + } + +
+ ); + } + return (
setCtxMenu(null)} > diff --git a/src/components/MenuBar/MenuBar.css b/src/components/MenuBar/MenuBar.css index 3e41357..a0aefaa 100644 --- a/src/components/MenuBar/MenuBar.css +++ b/src/components/MenuBar/MenuBar.css @@ -21,7 +21,7 @@ } .menubar-logo { - font-size: 14px; + font-size: 8px; margin-right: 8px; cursor: default; color: var(--pixel-cyan); diff --git a/src/components/MobileAppView/MobileAppView.css b/src/components/MobileAppView/MobileAppView.css new file mode 100644 index 0000000..1035fe0 --- /dev/null +++ b/src/components/MobileAppView/MobileAppView.css @@ -0,0 +1,53 @@ +.mobile-app-view { + position: fixed; + inset: 0; + background: #0d0d1a; + display: flex; + flex-direction: column; + z-index: 600; +} + +.mobile-app-topbar { + height: var(--mobile-statusbar-height); + background: #0d0d1a; + border-bottom: 2px solid rgba(0, 255, 255, 0.2); + display: flex; + align-items: center; + padding: 0 14px; + flex-shrink: 0; + gap: 8px; +} + +.mobile-back-btn { + font-family: var(--font-mono); + font-size: 10px; + color: var(--pixel-cyan); + letter-spacing: 0.5px; + cursor: pointer; + background: none; + border: none; + padding: 0; + -webkit-tap-highlight-color: transparent; + flex-shrink: 0; +} + +.mobile-app-title { + flex: 1; + text-align: center; + font-family: var(--font-mono); + font-size: 10px; + color: var(--pixel-cyan); + letter-spacing: 1px; +} + +.mobile-app-topbar-spacer { + flex-shrink: 0; + width: 36px; +} + +.mobile-app-content { + flex: 1; + overflow: auto; + color: #fff; + padding-bottom: env(safe-area-inset-bottom, 0px); +} diff --git a/src/components/MobileAppView/MobileAppView.tsx b/src/components/MobileAppView/MobileAppView.tsx new file mode 100644 index 0000000..f8c2698 --- /dev/null +++ b/src/components/MobileAppView/MobileAppView.tsx @@ -0,0 +1,53 @@ +import { useRef } from 'react'; +import { motion } from 'framer-motion'; +import { APPS } from '../../config/apps'; +import './MobileAppView.css'; + +interface Props { + appId: string; + onBack: () => void; +} + +export default function MobileAppView({ appId, onBack }: Props) { + const app = APPS.find(a => a.id === appId); + const dragStartY = useRef(null); + + if (!app) return null; + + const AppComponent = app.component; + + function handleDragStart(y: number) { + dragStartY.current = y; + } + + function handleDragEnd(y: number) { + if (dragStartY.current === null) return; + if (y - dragStartY.current > 60) onBack(); + dragStartY.current = null; + } + + return ( + handleDragStart(e.touches[0].clientY)} + onTouchEnd={e => handleDragEnd(e.changedTouches[0].clientY)} + onMouseDown={e => handleDragStart(e.clientY)} + onMouseUp={e => handleDragEnd(e.clientY)} + > +
+ + {app.title} + +
+
+ +
+
+ ); +} diff --git a/src/components/MobileHomeScreen/MobileHomeScreen.css b/src/components/MobileHomeScreen/MobileHomeScreen.css new file mode 100644 index 0000000..eab2e57 --- /dev/null +++ b/src/components/MobileHomeScreen/MobileHomeScreen.css @@ -0,0 +1,60 @@ +.mobile-home { + position: fixed; + inset: 0; + padding-top: var(--mobile-statusbar-height); + padding-bottom: env(safe-area-inset-bottom, 0px); + overflow-y: auto; +} + +.mobile-icon-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px 8px; + padding: 24px 12px 16px; +} + +.mobile-icon-cell { + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + background: none; + border: none; + cursor: pointer; + padding: 0; + -webkit-tap-highlight-color: transparent; +} + +.mobile-icon-img { + width: var(--mobile-icon-size); + height: var(--mobile-icon-size); + border: 2px solid #000; + box-shadow: 3px 3px 0 #000; + display: flex; + align-items: center; + justify-content: center; +} + +.mobile-icon-emoji { + font-size: 28px; + line-height: 1; +} + +.mobile-icon-custom { + width: 40px; + height: 40px; + object-fit: contain; + image-rendering: pixelated; +} + +.mobile-icon-label { + font-family: var(--font-mono); + font-size: 9px; + color: var(--pixel-cyan); + text-align: center; + letter-spacing: 0.4px; + max-width: 68px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/components/MobileHomeScreen/MobileHomeScreen.tsx b/src/components/MobileHomeScreen/MobileHomeScreen.tsx new file mode 100644 index 0000000..4a39b7e --- /dev/null +++ b/src/components/MobileHomeScreen/MobileHomeScreen.tsx @@ -0,0 +1,37 @@ +import { APPS } from '../../config/apps'; +import './MobileHomeScreen.css'; + +interface Props { + onOpenApp: (id: string) => void; +} + +export default function MobileHomeScreen({ onOpenApp }: Props) { + return ( +
+
+ {APPS.map(app => ( + + ))} +
+
+ ); +} diff --git a/src/components/MobileStatusBar/MobileStatusBar.css b/src/components/MobileStatusBar/MobileStatusBar.css new file mode 100644 index 0000000..8a9d709 --- /dev/null +++ b/src/components/MobileStatusBar/MobileStatusBar.css @@ -0,0 +1,28 @@ +.mobile-statusbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--mobile-statusbar-height); + background: #0d0d1a; + border-bottom: 2px solid rgba(0, 255, 255, 0.25); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 14px; + z-index: 1000; +} + +.mobile-statusbar-logo { + font-family: var(--font-mono); + font-size: 10px; + color: var(--pixel-cyan); + letter-spacing: 0.5px; +} + +.mobile-statusbar-clock { + font-family: var(--font-mono); + font-size: 11px; + color: var(--pixel-cyan); + letter-spacing: 1px; +} diff --git a/src/components/MobileStatusBar/MobileStatusBar.tsx b/src/components/MobileStatusBar/MobileStatusBar.tsx new file mode 100644 index 0000000..dac9ff8 --- /dev/null +++ b/src/components/MobileStatusBar/MobileStatusBar.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; +import './MobileStatusBar.css'; + +export default function MobileStatusBar() { + const [time, setTime] = useState(() => formatTime(new Date())); + + useEffect(() => { + const id = setInterval(() => setTime(formatTime(new Date())), 1000); + return () => clearInterval(id); + }, []); + + return ( +
+ [ MingdaOS ] + {time} +
+ ); +} + +function formatTime(d: Date) { + const hh = String(d.getUTCHours()).padStart(2, '0'); + const mm = String(d.getUTCMinutes()).padStart(2, '0'); + return `${hh}:${mm}`; +} diff --git a/src/context/WindowContext.tsx b/src/context/WindowContext.tsx index 7d545de..d9e14dd 100644 --- a/src/context/WindowContext.tsx +++ b/src/context/WindowContext.tsx @@ -25,6 +25,7 @@ const initialState: OSState = { windows: initWindows(), topZ: 10, wallpaper: 0, + activeAppId: null, }; function reducer(state: OSState, action: OSAction): OSState { @@ -144,6 +145,10 @@ function reducer(state: OSState, action: OSAction): OSState { }; case 'SET_WALLPAPER': return { ...state, wallpaper: action.index }; + case 'MOBILE_OPEN': + return { ...state, activeAppId: action.id }; + case 'MOBILE_CLOSE': + return { ...state, activeAppId: null }; default: return state; } @@ -159,6 +164,8 @@ interface WindowContextType { moveWindow: (id: string, x: number, y: number) => void; resizeWindow: (id: string, width: number, height: number) => void; setWallpaper: (index: number) => void; + openMobileApp: (id: string) => void; + closeMobileApp: () => void; } const WindowContext = createContext(null); @@ -174,9 +181,11 @@ export function WindowProvider({ children }: { children: React.ReactNode }) { const moveWindow = useCallback((id: string, x: number, y: number) => dispatch({ type: 'MOVE', id, x, y }), []); const resizeWindow = useCallback((id: string, w: number, h: number) => dispatch({ type: 'RESIZE', id, width: w, height: h }), []); const setWallpaper = useCallback((index: number) => dispatch({ type: 'SET_WALLPAPER', index }), []); + const openMobileApp = useCallback((id: string) => dispatch({ type: 'MOBILE_OPEN', id }), []); + const closeMobileApp = useCallback(() => dispatch({ type: 'MOBILE_CLOSE' }), []); return ( - + {children} ); diff --git a/src/hooks/useMobile.ts b/src/hooks/useMobile.ts new file mode 100644 index 0000000..bb26d57 --- /dev/null +++ b/src/hooks/useMobile.ts @@ -0,0 +1,16 @@ +import { useState, useEffect } from 'react'; + +const QUERY = '(max-width: 768px)'; + +export function useMobile(): boolean { + const [isMobile, setIsMobile] = useState(() => window.matchMedia(QUERY).matches); + + useEffect(() => { + const mql = window.matchMedia(QUERY); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, []); + + return isMobile; +} diff --git a/src/types/index.ts b/src/types/index.ts index b586db3..a87b9ac 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,6 +29,7 @@ export interface OSState { windows: Record; topZ: number; wallpaper: number; + activeAppId: string | null; } export type OSAction = @@ -39,4 +40,6 @@ export type OSAction = | { type: 'FOCUS'; id: string } | { type: 'MOVE'; id: string; x: number; y: number } | { type: 'RESIZE'; id: string; width: number; height: number } - | { type: 'SET_WALLPAPER'; index: number }; + | { type: 'SET_WALLPAPER'; index: number } + | { type: 'MOBILE_OPEN'; id: string } + | { type: 'MOBILE_CLOSE' };