windows/components/AppWindow.vue

362 lines
9.5 KiB
Vue
Raw Normal View History

2025-09-25 05:38:59 +00:00
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue';
import type { AppInstance } from '../stores/apps';
import { useAppsStore } from '../stores/apps';
import { useDraggable } from '../composables/useDraggable';
import type { SnapType } from '../composables/useDraggable';
import { useResizable } from '../composables/useResizable';
import { useBreakpoint } from '../composables/useBreakpoint';
import Calculator from './Calculator.vue';
const props = defineProps<{
instance: AppInstance;
}>();
const emit = defineEmits(['snap-preview', 'snap-execute']);
const appsStore = useAppsStore();
const { isMobile } = useBreakpoint();
const {
focusAppInstance,
closeAppInstance,
minimizeAppInstance,
toggleMaximizeAppInstance,
updateAppInstancePosition,
updateAppInstanceSize,
getAppById
} = appsStore;
const appInfo = computed(() => getAppById(props.instance.appId));
const windowRef = ref<HTMLElement | null>(null);
const titleBarRef = ref<HTMLElement | null>(null);
const isDragging = ref(false);
const isResizing = ref(false);
const isDraggableAndResizable = computed(() => !isMobile.value);
const position = computed(() => ({ x: props.instance.x, y: props.instance.y }));
const windowDimensions = computed(() => ({ width: props.instance.width, height: props.instance.height }));
const draggableConstraints = ref({ top: 48, right: window.innerWidth, bottom: window.innerHeight, left: 0 });
const updateConstraints = () => {
draggableConstraints.value = { top: 48, right: window.innerWidth, bottom: window.innerHeight, left: 0 };
};
onMounted(() => {
window.addEventListener('resize', updateConstraints);
updateConstraints();
});
onUnmounted(() => {
window.removeEventListener('resize', updateConstraints);
});
useDraggable(titleBarRef, {
position: position,
onDrag: (x, y) => {
if (!props.instance.isMaximized) {
updateAppInstancePosition({ id: props.instance.id, x, y });
}
},
onDragStart: () => { isDragging.value = true; },
onDragEnd: (snapType) => {
isDragging.value = false;
emit('snap-execute', { windowId: props.instance.id, snapType });
},
onSnap: (snapType: SnapType) => {
emit('snap-preview', snapType);
},
constraints: draggableConstraints,
targetSize: windowDimensions,
enabled: isDraggableAndResizable,
});
useResizable(windowRef, {
initialSize: { width: props.instance.width, height: props.instance.height },
initialPosition: { x: props.instance.x, y: props.instance.y },
onResize: ({ x, y, width, height }) => {
if (!props.instance.isMaximized) {
updateAppInstancePosition({ id: props.instance.id, x, y });
updateAppInstanceSize({ id: props.instance.id, width, height });
}
},
onResizeStart: () => { isResizing.value = true; },
onResizeEnd: () => { isResizing.value = false; },
constraints: draggableConstraints,
2025-09-25 06:38:14 +00:00
maxSize: {
width: props.instance.maxWidth,
height: props.instance.maxHeight
},
minSize: {
width: props.instance.minWidth,
height: props.instance.minHeight
},
2025-09-25 05:38:59 +00:00
enabled: isDraggableAndResizable,
});
const windowStyle = computed(() => ({
left: `${props.instance.x}px`,
top: `${props.instance.y}px`,
width: `${props.instance.width}px`,
height: `${props.instance.height}px`,
zIndex: props.instance.zIndex,
}));
const windowClasses = computed(() => ({
'is-maximized': props.instance.isMaximized,
'is-minimized': props.instance.isMinimized,
'is-focused': props.instance.isFocused,
'is-dragging': isDragging.value,
'is-resizing': isResizing.value,
'is-mobile': isMobile.value,
}));
function onMouseDown() {
focusAppInstance(props.instance.id);
}
// Dynamic component loading
const appComponent = computed(() => {
switch (props.instance.appId) {
case 'calculator':
return Calculator;
default:
return null;
}
});
</script>
<template>
<div
ref="windowRef"
class="app-window-container"
:style="windowStyle"
:class="windowClasses"
@mousedown="onMouseDown"
>
<div class="title-bar" ref="titleBarRef">
<div class="title-content">
<span class="app-icon">{{ appInfo?.icon }}</span>
<span class="title">{{ instance.title }}</span>
</div>
<div class="controls">
<button @click.stop="minimizeAppInstance(instance.id)" class="control-btn minimize" :title="$t('common.minimize')">
<span class="control-icon"></span>
</button>
<button @click.stop="toggleMaximizeAppInstance(instance.id)" class="control-btn maximize" :title="$t('common.maximize')">
<span class="control-icon"></span>
</button>
<button @click.stop="closeAppInstance(instance.id)" class="control-btn close" :title="$t('common.close')">
<span class="control-icon">×</span>
</button>
</div>
</div>
<div class="app-content">
<component
v-if="appComponent"
:is="appComponent"
:instance="instance"
/>
<div v-else class="app-not-found">
<p>App component not found</p>
<p>App ID: {{ instance.appId }}</p>
</div>
</div>
<div v-if="isDraggableAndResizable">
<div class="resizer" data-direction="n"></div>
<div class="resizer" data-direction="ne"></div>
<div class="resizer" data-direction="e"></div>
<div class="resizer" data-direction="se"></div>
<div class="resizer" data-direction="s"></div>
<div class="resizer" data-direction="sw"></div>
<div class="resizer" data-direction="w"></div>
<div class="resizer" data-direction="nw"></div>
</div>
</div>
</template>
<style scoped>
.app-window-container {
position: absolute;
background: var(--window-background);
border-radius: var(--rounded-window);
box-shadow: var(--shadow-window);
display: flex;
flex-direction: column;
transition: opacity 0.2s ease, transform 0.2s ease, width 0.2s ease, height 0.2s ease, left 0.2s ease, top 0.2s ease;
border: 1px solid var(--window-border-color);
}
.app-window-container.is-mobile {
left: 0 !important;
top: 48px !important;
width: 100vw !important;
height: calc(100vh - 48px) !important;
border-radius: 0 !important;
border: none !important;
box-shadow: none !important;
}
.app-window-container.is-dragging,
.app-window-container.is-resizing {
transition: none !important;
}
.app-window-container.is-focused {
border-color: var(--window-border-color-focused);
}
.app-window-container.is-maximized {
left: 0 !important;
top: 48px !important;
width: 100vw !important;
height: calc(100vh - 48px) !important;
border-radius: 0;
transition: width 0.2s ease, height 0.2s ease;
}
.app-window-container.is-minimized {
opacity: 0;
transform: translateY(200px);
pointer-events: none;
}
.title-bar {
height: 36px;
background: var(--title-bar-background);
color: var(--title-bar-text-color);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
flex-shrink: 0;
border-top-left-radius: var(--rounded-window);
border-top-right-radius: var(--rounded-window);
}
.app-window-container.is-maximized .title-bar,
.app-window-container.is-mobile .title-bar {
border-radius: 0;
}
.title-content {
display: flex;
align-items: center;
gap: 8px;
}
.app-icon {
font-size: 16px;
}
.title {
font-weight: bold;
user-select: none;
}
.controls {
display: flex;
gap: 8px;
}
.control-btn {
width: 20px;
height: 20px;
border: none;
border-radius: var(--rounded-control-btn);
display: flex;
justify-content: center;
align-items: center;
color: var(--title-bar-text-color);
cursor: pointer;
font-size: 12px;
line-height: 1;
transition: all 0.15s ease;
position: relative;
}
.control-btn:hover {
transform: scale(1.1);
opacity: 0.9;
}
.control-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-weight: bold;
font-size: 11px;
line-height: 1;
}
.minimize {
background: var(--control-btn-minimize-bg);
color: #000;
}
.minimize:hover {
background: #f39c12;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.maximize {
background: var(--control-btn-maximize-bg);
color: #000;
}
.maximize:hover {
background: #27ae60;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.close {
background: var(--control-btn-close-bg);
color: #fff;
}
.close:hover {
background: #c0392b;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.app-content {
flex-grow: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.app-not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--content-text-color);
text-align: center;
padding: 20px;
}
.resizer {
position: absolute;
z-index: 10;
}
.resizer[data-direction="n"] { top: -4px; left: 4px; right: 4px; height: 8px; cursor: ns-resize; }
.resizer[data-direction="s"] { bottom: -4px; left: 4px; right: 4px; height: 8px; cursor: ns-resize; }
.resizer[data-direction="e"] { top: 4px; right: -4px; bottom: 4px; width: 8px; cursor: ew-resize; }
.resizer[data-direction="w"] { top: 4px; left: -4px; bottom: 4px; width: 8px; cursor: ew-resize; }
.resizer[data-direction="ne"] { top: -4px; right: -4px; width: 12px; height: 12px; cursor: nesw-resize; }
.resizer[data-direction="sw"] { bottom: -4px; left: -4px; width: 12px; height: 12px; cursor: nesw-resize; }
.resizer[data-direction="nw"] { top: -4px; left: -4px; width: 12px; height: 12px; cursor: nwse-resize; }
.resizer[data-direction="se"] { bottom: -4px; right: -4px; width: 12px; height: 12px; cursor: nwse-resize; }
</style>