@cosider.construction/eapp-maker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,124 @@
1
+ <!-- ═══════════════════════════════════════════════════════════
2
+ AppConfirm.vue — yes/no/cancel dialog
3
+ Props: modelValue, title, message, confirmLabel, cancelLabel, danger
4
+ Emits: confirm, cancel, update:modelValue
5
+ ═══════════════════════════════════════════════════════════ -->
6
+ <script setup>
7
+ defineProps({
8
+ modelValue: { type: Boolean, default: false },
9
+ title: { type: String, default: 'Confirm' },
10
+ message: { type: String, default: 'Are you sure?' },
11
+ confirmLabel: { type: String, default: 'Confirm' },
12
+ cancelLabel: { type: String, default: 'Cancel' },
13
+ danger: { type: Boolean, default: false },
14
+ });
15
+ const emit = defineEmits(['confirm', 'cancel', 'update:modelValue']);
16
+ function confirm() { emit('confirm'); emit('update:modelValue', false); }
17
+ function cancel() { emit('cancel'); emit('update:modelValue', false); }
18
+ </script>
19
+
20
+ <template>
21
+ <teleport to="body">
22
+ <transition name="modal-fade">
23
+ <div v-if="modelValue" class="app-confirm-overlay" @click.self="cancel">
24
+ <div class="app-confirm">
25
+ <div class="app-confirm__title">{{ title }}</div>
26
+ <div class="app-confirm__message">{{ message }}</div>
27
+ <div class="app-confirm__actions">
28
+ <button class="btn btn--ghost" @click="cancel">{{ cancelLabel }}</button>
29
+ <button :class="['btn', danger ? 'btn--danger' : 'btn--primary']" @click="confirm">{{ confirmLabel }}</button>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </transition>
34
+ </teleport>
35
+ </template>
36
+
37
+ <style scoped>
38
+ .app-confirm-overlay {
39
+ position: fixed; inset: 0; z-index: 300;
40
+ background: rgba(0,0,0,.55);
41
+ display: flex; align-items: center; justify-content: center;
42
+ }
43
+ .app-confirm {
44
+ background: #313244; border-radius: 10px; padding: 1.5rem;
45
+ min-width: 320px; max-width: 480px;
46
+ box-shadow: 0 20px 60px rgba(0,0,0,.5);
47
+ }
48
+ .app-confirm__title { font-weight: 700; color: #cdd6f4; font-size: 1rem; margin-bottom: .5rem; }
49
+ .app-confirm__message { color: #a6adc8; font-size: .9rem; line-height: 1.5; margin-bottom: 1.25rem; }
50
+ .app-confirm__actions { display: flex; justify-content: flex-end; gap: .5rem; }
51
+ .btn { padding: .45rem 1rem; border-radius: 6px; border: none; cursor: pointer; font-size: .85rem; font-weight: 500; }
52
+ .btn--primary { background: #89b4fa; color: #1e1e2e; }
53
+ .btn--primary:hover { background: #74c7ec; }
54
+ .btn--danger { background: #f38ba8; color: #1e1e2e; }
55
+ .btn--danger:hover { background: #eb6f92; }
56
+ .btn--ghost { background: transparent; color: #a6adc8; border: 1px solid #45475a; }
57
+ .btn--ghost:hover { background: rgba(255,255,255,.06); }
58
+ .modal-fade-enter-active, .modal-fade-leave-active { transition: opacity .15s ease; }
59
+ .modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
60
+ </style>
61
+
62
+
63
+ <!-- ═══════════════════════════════════════════════════════════
64
+ AppAlert.vue — simple ok/dismiss dialog
65
+ Props: modelValue, title, message, type (info|warning|error|success)
66
+ Emits: ok, update:modelValue
67
+ ═══════════════════════════════════════════════════════════ -->
68
+ <script setup>
69
+ defineProps({
70
+ modelValue: { type: Boolean, default: false },
71
+ title: { type: String, default: 'Alert' },
72
+ message: { type: String, default: '' },
73
+ type: { type: String, default: 'info' }, // info | warning | error | success
74
+ okLabel: { type: String, default: 'OK' },
75
+ });
76
+ const emit = defineEmits(['ok', 'update:modelValue']);
77
+ function ok() { emit('ok'); emit('update:modelValue', false); }
78
+ </script>
79
+
80
+ <template>
81
+ <teleport to="body">
82
+ <transition name="modal-fade">
83
+ <div v-if="modelValue" class="app-alert-overlay" @click.self="ok">
84
+ <div :class="['app-alert', `app-alert--${type}`]">
85
+ <div class="app-alert__icon">
86
+ {{ type === 'error' ? '✖' : type === 'warning' ? '⚠' : type === 'success' ? '✔' : 'ℹ' }}
87
+ </div>
88
+ <div class="app-alert__content">
89
+ <div class="app-alert__title">{{ title }}</div>
90
+ <div v-if="message" class="app-alert__message">{{ message }}</div>
91
+ </div>
92
+ <button class="btn btn--primary" @click="ok">{{ okLabel }}</button>
93
+ </div>
94
+ </div>
95
+ </transition>
96
+ </teleport>
97
+ </template>
98
+
99
+ <style scoped>
100
+ .app-alert-overlay {
101
+ position: fixed; inset: 0; z-index: 300;
102
+ background: rgba(0,0,0,.55);
103
+ display: flex; align-items: center; justify-content: center;
104
+ }
105
+ .app-alert {
106
+ background: #313244; border-radius: 10px; padding: 1.5rem;
107
+ min-width: 320px; max-width: 440px;
108
+ display: flex; align-items: flex-start; gap: 1rem;
109
+ box-shadow: 0 20px 60px rgba(0,0,0,.5);
110
+ flex-direction: column;
111
+ }
112
+ .app-alert__icon { font-size: 1.5rem; }
113
+ .app-alert--info .app-alert__icon { color: #89b4fa; }
114
+ .app-alert--warning .app-alert__icon { color: #f9e2af; }
115
+ .app-alert--error .app-alert__icon { color: #f38ba8; }
116
+ .app-alert--success .app-alert__icon { color: #a6e3a1; }
117
+ .app-alert__content { flex: 1; }
118
+ .app-alert__title { font-weight: 700; color: #cdd6f4; margin-bottom: .35rem; }
119
+ .app-alert__message { color: #a6adc8; font-size: .88rem; line-height: 1.5; }
120
+ .btn { padding: .45rem 1rem; border-radius: 6px; border: none; cursor: pointer; font-size: .85rem; font-weight: 500; align-self: flex-end; }
121
+ .btn--primary { background: #89b4fa; color: #1e1e2e; }
122
+ .modal-fade-enter-active, .modal-fade-leave-active { transition: opacity .15s ease; }
123
+ .modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
124
+ </style>
@@ -0,0 +1,226 @@
1
+ <script setup>
2
+ import { ref, computed, inject } from 'vue';
3
+ import { useRouter, useRoute } from 'vue-router';
4
+
5
+ const props = defineProps({
6
+ config: { type: Object, default: null },
7
+ mode: { type: String, default: null }, // 'classic' | 'ribbon' — overrides config
8
+ sub: { type: Boolean, default: false }, // render as secondary sub-bar
9
+ });
10
+
11
+ const emit = defineEmits(['action']);
12
+ const route = useRoute();
13
+
14
+ // If no config provided, try to inject from layout
15
+ const injected = inject('hMenuConfig', null);
16
+ const activeConf = computed(() => props.config || injected || { style: 'classic', items: [] });
17
+ const activeMode = computed(() => props.mode || activeConf.value.style || 'classic');
18
+
19
+ // Classic mode state
20
+ const openDropdown = ref(null);
21
+
22
+ function toggleDropdown(label) {
23
+ openDropdown.value = openDropdown.value === label ? null : label;
24
+ }
25
+
26
+ function isActive(item) {
27
+ return item.route && route.path.startsWith(item.route);
28
+ }
29
+
30
+ // Ribbon mode state
31
+ const activeTab = ref(0);
32
+
33
+ function handleAction(actionStr) {
34
+ // action format: "emit:save" | "route:/path" | "fn:methodName"
35
+ if (!actionStr) return;
36
+ const [type, value] = actionStr.split(':');
37
+ if (type === 'emit') emit('action', value);
38
+ if (type === 'route') useRouter().push(value);
39
+ }
40
+ </script>
41
+
42
+ <template>
43
+ <!-- ── Classic mode ─────────────────────────────────────── -->
44
+ <nav
45
+ v-if="activeMode === 'classic'"
46
+ :class="['app-menu-h', 'app-menu-h--classic', { 'app-menu-h--sub': sub }]"
47
+ >
48
+ <template v-for="item in activeConf.items" :key="item.label">
49
+ <!-- Item with children → dropdown -->
50
+ <div v-if="item.children?.length" class="amh-item amh-item--parent">
51
+ <button
52
+ :class="['amh-btn', { 'amh-btn--active': isActive(item) }]"
53
+ @click="toggleDropdown(item.label)"
54
+ >
55
+ <span v-if="item.icon" class="amh-icon">{{ item.icon }}</span>
56
+ {{ item.label }}
57
+ <span class="amh-arrow">▾</span>
58
+ </button>
59
+ <div v-if="openDropdown === item.label" class="amh-dropdown">
60
+ <router-link
61
+ v-for="child in item.children"
62
+ :key="child.label"
63
+ :to="child.route"
64
+ class="amh-dropdown-item"
65
+ @click="openDropdown = null"
66
+ >
67
+ <span v-if="child.icon" class="amh-icon">{{ child.icon }}</span>
68
+ {{ child.label }}
69
+ </router-link>
70
+ </div>
71
+ </div>
72
+
73
+ <!-- Simple link -->
74
+ <router-link
75
+ v-else-if="item.route"
76
+ :to="item.route"
77
+ :class="['amh-btn', { 'amh-btn--active': isActive(item) }]"
78
+ >
79
+ <span v-if="item.icon" class="amh-icon">{{ item.icon }}</span>
80
+ {{ item.label }}
81
+ </router-link>
82
+
83
+ <!-- Action button -->
84
+ <button
85
+ v-else-if="item.action"
86
+ class="amh-btn amh-btn--action"
87
+ @click="handleAction(item.action)"
88
+ >
89
+ <span v-if="item.icon" class="amh-icon">{{ item.icon }}</span>
90
+ {{ item.label }}
91
+ </button>
92
+ </template>
93
+ </nav>
94
+
95
+ <!-- ── Ribbon mode ───────────────────────────────────────── -->
96
+ <div
97
+ v-else-if="activeMode === 'ribbon'"
98
+ :class="['app-menu-h', 'app-menu-h--ribbon', { 'app-menu-h--sub': sub }]"
99
+ >
100
+ <!-- Tab headers -->
101
+ <div class="amh-ribbon-tabs">
102
+ <button
103
+ v-for="(tab, i) in activeConf.tabs"
104
+ :key="tab.label"
105
+ :class="['amh-ribbon-tab', { 'amh-ribbon-tab--active': activeTab === i }]"
106
+ @click="activeTab = i"
107
+ >
108
+ {{ tab.label }}
109
+ </button>
110
+ </div>
111
+
112
+ <!-- Active tab content -->
113
+ <div v-if="activeConf.tabs?.[activeTab]" class="amh-ribbon-body">
114
+ <div
115
+ v-for="group in activeConf.tabs[activeTab].groups"
116
+ :key="group.label"
117
+ class="amh-ribbon-group"
118
+ >
119
+ <div class="amh-ribbon-group-items">
120
+ <button
121
+ v-for="item in group.items"
122
+ :key="item.label"
123
+ class="amh-ribbon-item"
124
+ :title="item.label"
125
+ @click="handleAction(item.action)"
126
+ >
127
+ <span class="amh-ribbon-icon">{{ item.icon || '▪' }}</span>
128
+ <span class="amh-ribbon-label">{{ item.label }}</span>
129
+ </button>
130
+ </div>
131
+ <div class="amh-ribbon-group-label">{{ group.label }}</div>
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </template>
136
+
137
+ <style scoped>
138
+ /* ── Base ── */
139
+ .app-menu-h { position: relative; z-index: 50; }
140
+
141
+ /* ── Classic ── */
142
+ .app-menu-h--classic {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 2px;
146
+ padding: 0 .75rem;
147
+ background: #1e1e2e;
148
+ border-bottom: 1px solid #313244;
149
+ min-height: 40px;
150
+ }
151
+ .app-menu-h--classic.app-menu-h--sub {
152
+ background: #181825;
153
+ min-height: 34px;
154
+ border-bottom: 1px solid #45475a;
155
+ }
156
+ .amh-item--parent { position: relative; }
157
+ .amh-btn {
158
+ display: flex; align-items: center; gap: .35rem;
159
+ padding: .35rem .65rem; border-radius: 4px;
160
+ background: none; border: none; cursor: pointer;
161
+ color: #cdd6f4; font-size: .85rem; text-decoration: none;
162
+ white-space: nowrap;
163
+ }
164
+ .amh-btn:hover { background: rgba(255,255,255,.08); }
165
+ .amh-btn--active { background: rgba(137,180,250,.15); color: #89b4fa; }
166
+ .amh-btn--action { color: #a6e3a1; }
167
+ .amh-arrow { font-size: .7rem; opacity: .6; }
168
+ .amh-icon { font-size: .9rem; }
169
+ .amh-dropdown {
170
+ position: absolute; top: 100%; left: 0;
171
+ background: #313244; border: 1px solid #45475a;
172
+ border-radius: 6px; min-width: 180px;
173
+ box-shadow: 0 8px 24px rgba(0,0,0,.4);
174
+ overflow: hidden; z-index: 100;
175
+ }
176
+ .amh-dropdown-item {
177
+ display: flex; align-items: center; gap: .5rem;
178
+ padding: .5rem .85rem; color: #cdd6f4;
179
+ text-decoration: none; font-size: .85rem;
180
+ }
181
+ .amh-dropdown-item:hover { background: rgba(255,255,255,.08); }
182
+
183
+ /* ── Ribbon ── */
184
+ .app-menu-h--ribbon {
185
+ background: #1e1e2e;
186
+ border-bottom: 1px solid #313244;
187
+ }
188
+ .app-menu-h--ribbon.app-menu-h--sub { background: #181825; }
189
+ .amh-ribbon-tabs {
190
+ display: flex; gap: 1px;
191
+ padding: .25rem .5rem 0;
192
+ background: #181825;
193
+ }
194
+ .amh-ribbon-tab {
195
+ padding: .3rem .9rem; border-radius: 4px 4px 0 0;
196
+ background: none; border: none; cursor: pointer;
197
+ color: #a6adc8; font-size: .8rem;
198
+ }
199
+ .amh-ribbon-tab:hover { background: rgba(255,255,255,.06); color: #cdd6f4; }
200
+ .amh-ribbon-tab--active { background: #1e1e2e; color: #89b4fa; border-bottom: 2px solid #89b4fa; }
201
+ .amh-ribbon-body {
202
+ display: flex; gap: 1px;
203
+ padding: .4rem .75rem;
204
+ min-height: 72px; align-items: stretch;
205
+ }
206
+ .amh-ribbon-group {
207
+ display: flex; flex-direction: column;
208
+ border-right: 1px solid #313244;
209
+ padding: 0 .75rem 0 0; margin-right: .5rem;
210
+ }
211
+ .amh-ribbon-group:last-child { border-right: none; }
212
+ .amh-ribbon-group-items { display: flex; gap: 4px; flex: 1; align-items: center; }
213
+ .amh-ribbon-group-label {
214
+ font-size: .68rem; color: #6c7086; text-align: center;
215
+ padding-top: 2px; border-top: 1px solid #313244; margin-top: 4px;
216
+ }
217
+ .amh-ribbon-item {
218
+ display: flex; flex-direction: column; align-items: center;
219
+ gap: 2px; padding: .3rem .5rem; border-radius: 4px;
220
+ background: none; border: none; cursor: pointer;
221
+ color: #cdd6f4; min-width: 44px;
222
+ }
223
+ .amh-ribbon-item:hover { background: rgba(255,255,255,.08); }
224
+ .amh-ribbon-icon { font-size: 1.2rem; }
225
+ .amh-ribbon-label { font-size: .68rem; white-space: nowrap; }
226
+ </style>
@@ -0,0 +1,146 @@
1
+ <script setup>
2
+ import { ref, computed, watch } from 'vue';
3
+ import { useRoute } from 'vue-router';
4
+
5
+ const props = defineProps({
6
+ config: { type: Object, default: () => ({ items: [] }) },
7
+ });
8
+
9
+ const route = useRoute();
10
+ const expanded = ref({});
11
+
12
+ // Auto-expand the group that contains the active route
13
+ watch(() => route.path, (p) => {
14
+ for (const item of props.config.items || []) {
15
+ if (item.children?.some(c => p.startsWith(c.route))) {
16
+ expanded.value[item.label] = true;
17
+ }
18
+ }
19
+ }, { immediate: true });
20
+
21
+ function toggle(label) {
22
+ expanded.value[label] = !expanded.value[label];
23
+ }
24
+
25
+ function isGroupActive(item) {
26
+ return item.children?.some(c => route.path.startsWith(c.route));
27
+ }
28
+
29
+ function isActive(item) {
30
+ return item.route && route.path.startsWith(item.route);
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <aside class="app-menu-v">
36
+ <div class="amv-inner">
37
+ <template v-for="item in config.items" :key="item.label">
38
+
39
+ <!-- Group with children -->
40
+ <div v-if="item.children?.length" class="amv-group">
41
+ <button
42
+ :class="['amv-group-header', { 'amv-group-header--active': isGroupActive(item) }]"
43
+ @click="toggle(item.label)"
44
+ >
45
+ <span class="amv-group-icon">{{ item.icon || '▪' }}</span>
46
+ <span class="amv-group-label">{{ item.label }}</span>
47
+ <span :class="['amv-group-arrow', { 'amv-group-arrow--open': expanded[item.label] }]">›</span>
48
+ </button>
49
+
50
+ <transition name="amv-slide">
51
+ <div v-if="expanded[item.label]" class="amv-children">
52
+ <router-link
53
+ v-for="child in item.children"
54
+ :key="child.label"
55
+ :to="child.route"
56
+ :class="['amv-child', { 'amv-child--active': isActive(child) }]"
57
+ >
58
+ <span class="amv-child-icon">{{ child.icon || '·' }}</span>
59
+ {{ child.label }}
60
+ </router-link>
61
+ </div>
62
+ </transition>
63
+ </div>
64
+
65
+ <!-- Simple link -->
66
+ <router-link
67
+ v-else-if="item.route"
68
+ :to="item.route"
69
+ :class="['amv-link', { 'amv-link--active': isActive(item) }]"
70
+ >
71
+ <span class="amv-link-icon">{{ item.icon || '▪' }}</span>
72
+ {{ item.label }}
73
+ </router-link>
74
+
75
+ <!-- Divider -->
76
+ <div v-else-if="item.divider" class="amv-divider" />
77
+
78
+ </template>
79
+ </div>
80
+ </aside>
81
+ </template>
82
+
83
+ <style scoped>
84
+ .app-menu-v {
85
+ width: 220px;
86
+ background: #181825;
87
+ border-right: 1px solid #313244;
88
+ display: flex;
89
+ flex-direction: column;
90
+ overflow: hidden;
91
+ flex-shrink: 0;
92
+ }
93
+ .amv-inner {
94
+ flex: 1;
95
+ overflow-y: auto;
96
+ padding: .5rem 0;
97
+ }
98
+
99
+ /* Group */
100
+ .amv-group { margin-bottom: 2px; }
101
+ .amv-group-header {
102
+ display: flex; align-items: center; gap: .5rem;
103
+ width: 100%; padding: .45rem .85rem;
104
+ background: none; border: none; cursor: pointer;
105
+ color: #a6adc8; font-size: .82rem; text-align: left;
106
+ }
107
+ .amv-group-header:hover { background: rgba(255,255,255,.05); color: #cdd6f4; }
108
+ .amv-group-header--active { color: #89b4fa; }
109
+ .amv-group-icon { font-size: .9rem; width: 16px; text-align: center; }
110
+ .amv-group-label { flex: 1; font-weight: 500; }
111
+ .amv-group-arrow {
112
+ font-size: .9rem; transition: transform .2s; display: inline-block;
113
+ }
114
+ .amv-group-arrow--open { transform: rotate(90deg); }
115
+
116
+ /* Children */
117
+ .amv-children { padding-left: .5rem; }
118
+ .amv-child {
119
+ display: flex; align-items: center; gap: .5rem;
120
+ padding: .35rem .85rem .35rem 1.4rem;
121
+ color: #a6adc8; text-decoration: none; font-size: .8rem;
122
+ border-radius: 4px; margin: 1px .4rem;
123
+ }
124
+ .amv-child:hover { background: rgba(255,255,255,.05); color: #cdd6f4; }
125
+ .amv-child--active { background: rgba(137,180,250,.12); color: #89b4fa; }
126
+ .amv-child-icon { font-size: .75rem; opacity: .6; }
127
+
128
+ /* Simple link */
129
+ .amv-link {
130
+ display: flex; align-items: center; gap: .5rem;
131
+ padding: .45rem .85rem; color: #a6adc8;
132
+ text-decoration: none; font-size: .82rem;
133
+ border-radius: 4px; margin: 1px .4rem;
134
+ }
135
+ .amv-link:hover { background: rgba(255,255,255,.05); color: #cdd6f4; }
136
+ .amv-link--active { background: rgba(137,180,250,.12); color: #89b4fa; }
137
+ .amv-link-icon { font-size: .9rem; width: 16px; text-align: center; }
138
+
139
+ /* Divider */
140
+ .amv-divider { height: 1px; background: #313244; margin: .4rem .85rem; }
141
+
142
+ /* Slide transition */
143
+ .amv-slide-enter-active, .amv-slide-leave-active { transition: all .18s ease; overflow: hidden; }
144
+ .amv-slide-enter-from, .amv-slide-leave-to { max-height: 0; opacity: 0; }
145
+ .amv-slide-enter-to, .amv-slide-leave-from { max-height: 400px; opacity: 1; }
146
+ </style>
@@ -0,0 +1,59 @@
1
+ <!-- ═══════════════════════════════════════════════════════════
2
+ AppModal.vue — generic modal wrapper
3
+ Props: modelValue (v-model), title, width
4
+ Slots: default (content), footer
5
+ ═══════════════════════════════════════════════════════════ -->
6
+ <script setup>
7
+ defineProps({
8
+ modelValue: { type: Boolean, default: false },
9
+ title: { type: String, default: '' },
10
+ width: { type: String, default: '520px' },
11
+ });
12
+ defineEmits(['update:modelValue']);
13
+ </script>
14
+
15
+ <template>
16
+ <teleport to="body">
17
+ <transition name="modal-fade">
18
+ <div v-if="modelValue" class="app-modal-overlay" @click.self="$emit('update:modelValue', false)">
19
+ <div class="app-modal" :style="{ width }">
20
+ <div v-if="title" class="app-modal__header">
21
+ <span class="app-modal__title">{{ title }}</span>
22
+ <button class="app-modal__close" @click="$emit('update:modelValue', false)">✕</button>
23
+ </div>
24
+ <div class="app-modal__body">
25
+ <slot />
26
+ </div>
27
+ <div v-if="$slots.footer" class="app-modal__footer">
28
+ <slot name="footer" />
29
+ </div>
30
+ </div>
31
+ </div>
32
+ </transition>
33
+ </teleport>
34
+ </template>
35
+
36
+ <style scoped>
37
+ .app-modal-overlay {
38
+ position: fixed; inset: 0; z-index: 200;
39
+ background: rgba(0,0,0,.5);
40
+ display: flex; align-items: center; justify-content: center;
41
+ }
42
+ .app-modal {
43
+ background: #313244; border-radius: 10px;
44
+ box-shadow: 0 20px 60px rgba(0,0,0,.5);
45
+ max-height: 90vh; display: flex; flex-direction: column;
46
+ overflow: hidden;
47
+ }
48
+ .app-modal__header {
49
+ display: flex; align-items: center; justify-content: space-between;
50
+ padding: 1rem 1.25rem; border-bottom: 1px solid #45475a;
51
+ }
52
+ .app-modal__title { font-weight: 600; color: #cdd6f4; font-size: .95rem; }
53
+ .app-modal__close { background: none; border: none; color: #6c7086; cursor: pointer; font-size: 1rem; padding: .2rem .4rem; border-radius: 4px; }
54
+ .app-modal__close:hover { background: rgba(255,255,255,.08); color: #cdd6f4; }
55
+ .app-modal__body { padding: 1.25rem; overflow-y: auto; flex: 1; color: #cdd6f4; }
56
+ .app-modal__footer { padding: .85rem 1.25rem; border-top: 1px solid #45475a; display: flex; justify-content: flex-end; gap: .5rem; }
57
+ .modal-fade-enter-active, .modal-fade-leave-active { transition: opacity .18s ease; }
58
+ .modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }
59
+ </style>
@@ -0,0 +1,69 @@
1
+ <script setup>
2
+ import { ref } from 'vue';
3
+
4
+ // ── Toast store (module-level singleton) ──────────────────────────────────────
5
+ const toasts = ref([]);
6
+ let _id = 0;
7
+
8
+ export function useToast() {
9
+ function show({ message, type = 'info', duration = 3500 }) {
10
+ const id = ++_id;
11
+ toasts.value.push({ id, message, type });
12
+ setTimeout(() => dismiss(id), duration);
13
+ }
14
+ const success = (msg, d) => show({ message: msg, type: 'success', duration: d });
15
+ const error = (msg, d) => show({ message: msg, type: 'error', duration: d });
16
+ const warn = (msg, d) => show({ message: msg, type: 'warning', duration: d });
17
+ const info = (msg, d) => show({ message: msg, type: 'info', duration: d });
18
+ return { show, success, error, warn, info };
19
+ }
20
+
21
+ function dismiss(id) {
22
+ toasts.value = toasts.value.filter(t => t.id !== id);
23
+ }
24
+ </script>
25
+
26
+ <template>
27
+ <teleport to="body">
28
+ <div class="app-toast-container">
29
+ <transition-group name="toast">
30
+ <div
31
+ v-for="toast in toasts"
32
+ :key="toast.id"
33
+ :class="['app-toast', `app-toast--${toast.type}`]"
34
+ @click="dismiss(toast.id)"
35
+ >
36
+ <span class="app-toast__icon">
37
+ {{ toast.type === 'success' ? '✔' : toast.type === 'error' ? '✖' : toast.type === 'warning' ? '⚠' : 'ℹ' }}
38
+ </span>
39
+ <span class="app-toast__message">{{ toast.message }}</span>
40
+ </div>
41
+ </transition-group>
42
+ </div>
43
+ </teleport>
44
+ </template>
45
+
46
+ <style scoped>
47
+ .app-toast-container {
48
+ position: fixed; bottom: 1.5rem; right: 1.5rem;
49
+ z-index: 400; display: flex; flex-direction: column; gap: .5rem;
50
+ pointer-events: none;
51
+ }
52
+ .app-toast {
53
+ display: flex; align-items: center; gap: .65rem;
54
+ padding: .7rem 1.1rem; border-radius: 8px;
55
+ box-shadow: 0 4px 20px rgba(0,0,0,.4);
56
+ font-size: .875rem; cursor: pointer; pointer-events: all;
57
+ min-width: 260px; max-width: 400px;
58
+ }
59
+ .app-toast--info { background: #1e3a5f; color: #89b4fa; border-left: 3px solid #89b4fa; }
60
+ .app-toast--success { background: #1a3a2a; color: #a6e3a1; border-left: 3px solid #a6e3a1; }
61
+ .app-toast--warning { background: #3a2e1a; color: #f9e2af; border-left: 3px solid #f9e2af; }
62
+ .app-toast--error { background: #3a1a1a; color: #f38ba8; border-left: 3px solid #f38ba8; }
63
+ .app-toast__icon { font-size: 1rem; flex-shrink: 0; }
64
+ .app-toast__message { flex: 1; line-height: 1.4; }
65
+ .toast-enter-active { transition: all .2s ease; }
66
+ .toast-leave-active { transition: all .25s ease; }
67
+ .toast-enter-from { opacity: 0; transform: translateX(20px); }
68
+ .toast-leave-to { opacity: 0; transform: translateX(20px); }
69
+ </style>
@@ -0,0 +1,29 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import { useRoute } from 'vue-router';
4
+ import AppMenuH from '@/components/AppMenuH.vue';
5
+ import AppMenuV from '@/components/AppMenuV.vue';
6
+ import { useMenuLoader } from '@/loaders/loader.menu.js';
7
+
8
+ const route = useRoute();
9
+ const { vMenuTree, currentHMenu, currentSubHMenu } = useMenuLoader();
10
+ </script>
11
+
12
+ <template>
13
+ <div class="default-layout">
14
+ <AppMenuH v-if="currentHMenu" :config="currentHMenu" />
15
+ <AppMenuH v-if="currentSubHMenu" :config="currentSubHMenu" sub />
16
+ <div class="default-layout__body">
17
+ <AppMenuV :config="{ items: vMenuTree }" />
18
+ <main class="default-layout__main">
19
+ <slot />
20
+ </main>
21
+ </div>
22
+ </div>
23
+ </template>
24
+
25
+ <style scoped>
26
+ .default-layout { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
27
+ .default-layout__body { display: flex; flex: 1; overflow: hidden; }
28
+ .default-layout__main { flex: 1; overflow: auto; padding: 1.5rem; background: #1e1e2e; }
29
+ </style>