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' };