feat: implement mobile interface with status bar, home screen, and app view components
Deploy / deploy (push) Successful in 15s

This commit is contained in:
M1ngdaXie
2026-05-31 17:08:53 +08:00
parent c8a6fc2871
commit f6a21d8084
13 changed files with 316 additions and 9 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=1.0.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="version" content="1.0.0" />
<meta http-equiv="Cache-Control" content="no-cache, must-revalidate" />
<title>MingdaOS</title>
+2
View File
@@ -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;
+27 -5
View File
@@ -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<CtxMenu | null>(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 (
<div className="desktop" style={wallpaperStyle}>
<MobileStatusBar />
<AnimatePresence mode="wait">
{state.activeAppId == null
? <MobileHomeScreen key="home" onOpenApp={openMobileApp} />
: <MobileAppView key={state.activeAppId} appId={state.activeAppId} onBack={closeMobileApp} />
}
</AnimatePresence>
</div>
);
}
return (
<div
className="desktop"
style={{
background: WALLPAPERS[state.wallpaper],
backgroundSize: WALLPAPERS[state.wallpaper].startsWith('url(') ? 'cover' : undefined,
}}
style={wallpaperStyle}
onContextMenu={handleContextMenu}
onClick={() => setCtxMenu(null)}
>
+1 -1
View File
@@ -21,7 +21,7 @@
}
.menubar-logo {
font-size: 14px;
font-size: 8px;
margin-right: 8px;
cursor: default;
color: var(--pixel-cyan);
@@ -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);
}
@@ -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<number | null>(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 (
<motion.div
className="mobile-app-view"
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.22, ease: 'easeOut' }}
onTouchStart={e => handleDragStart(e.touches[0].clientY)}
onTouchEnd={e => handleDragEnd(e.changedTouches[0].clientY)}
onMouseDown={e => handleDragStart(e.clientY)}
onMouseUp={e => handleDragEnd(e.clientY)}
>
<div className="mobile-app-topbar">
<button className="mobile-back-btn" onClick={onBack}>
back
</button>
<span className="mobile-app-title">{app.title}</span>
<span className="mobile-app-topbar-spacer" />
</div>
<div className="mobile-app-content">
<AppComponent />
</div>
</motion.div>
);
}
@@ -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;
}
@@ -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 (
<div className="mobile-home">
<div className="mobile-icon-grid">
{APPS.map(app => (
<button
key={app.id}
className="mobile-icon-cell"
onClick={() =>
app.externalUrl
? window.open(app.externalUrl, '_blank', 'noopener,noreferrer')
: onOpenApp(app.id)
}
>
<div
className="mobile-icon-img"
style={{ background: app.iconGradient }}
>
{app.iconImage
? <img src={app.iconImage} alt={app.title} className="mobile-icon-custom" />
: <span className="mobile-icon-emoji">{app.emoji}</span>
}
</div>
<span className="mobile-icon-label">{app.title}</span>
</button>
))}
</div>
</div>
);
}
@@ -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;
}
@@ -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 (
<div className="mobile-statusbar">
<span className="mobile-statusbar-logo">[ MingdaOS ]</span>
<span className="mobile-statusbar-clock">{time}</span>
</div>
);
}
function formatTime(d: Date) {
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(d.getUTCMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
+10 -1
View File
@@ -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<WindowContextType | null>(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 (
<WindowContext.Provider value={{ state, openWindow, closeWindow, minimizeWindow, maximizeWindow, focusWindow, moveWindow, resizeWindow, setWallpaper }}>
<WindowContext.Provider value={{ state, openWindow, closeWindow, minimizeWindow, maximizeWindow, focusWindow, moveWindow, resizeWindow, setWallpaper, openMobileApp, closeMobileApp }}>
{children}
</WindowContext.Provider>
);
+16
View File
@@ -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;
}
+4 -1
View File
@@ -29,6 +29,7 @@ export interface OSState {
windows: Record<string, WindowState>;
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' };