Initial commit

This commit is contained in:
root
2025-12-21 09:53:53 -05:00
committed by Sebastian Krupinski
commit 566f8f9e91
17 changed files with 2783 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Frontend development
node_modules/
*.local
.env.local
.env.*.local
.cache/
.vite/
.temp/
.tmp/
# Frontend build
/static/
# Backend development
/lib/vendor/
coverage/
phpunit.xml.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
.DS_Store
.vscode/
.idea/
# Logs
*.log

9
composer.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "ktxm/module_manager",
"type": "project",
"autoload": {
"psr-4": {
"KTXM\\ModuleManager\\": "lib/"
}
}
}

80
lib/Module.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
namespace KTXM\ModuleManager;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleInstanceAbstract;
/**
* Module Manager Module
*/
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
{
public function __construct()
{ }
public function handle(): string
{
return 'module_manager';
}
public function label(): string
{
return 'Module Manager';
}
public function author(): string
{
return 'Ktrix';
}
public function description(): string
{
return 'Module management interface - provides module listing, installation, uninstallation, enabling, disabling, and upgrading capabilities';
}
public function version(): string
{
return '0.0.1';
}
public function permissions(): array
{
return [
'module_manager' => [
'label' => 'Access Module Manager',
'description' => 'View and access the module manager interface',
'group' => 'Module Management'
],
'module_manager.modules.view' => [
'label' => 'View Modules',
'description' => 'View list of installed and available modules',
'group' => 'Module Management'
],
'module_manager.modules.manage' => [
'label' => 'Manage Modules',
'description' => 'Install, uninstall, enable, and disable modules',
'group' => 'Module Management'
],
'module_manager.modules.*' => [
'label' => 'Full Module Management',
'description' => 'All module management operations',
'group' => 'Module Management'
],
];
}
public function registerBI(): array
{
return [
'handle' => $this->handle(),
'namespace' => 'ModuleManager',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
}
}

2136
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "module_manager",
"description": "Ktrix Module Manager Module",
"version": "0.0.1",
"private": true,
"license": "AGPL-3.0-or-later",
"author": "Ktrix",
"type": "module",
"scripts": {
"build": "vite build --mode production --config vite.config.ts",
"dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@mdi/js": "^7.4.47",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vuetify": "^3.7.6"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"sass": "^1.77.1",
"typescript": "^5.7.2",
"vite": "^6.0.6",
"vite-plugin-vuetify": "^2.1.0",
"vue-tsc": "^2.2.10"
}
}

15
src/integrations.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
const integrations: ModuleIntegrations = {
admin_settings_menu: [
{
id: 'module_manager',
label: 'Modules',
path: '/modules',
icon: 'mdi-package-variant',
priority: 100,
},
],
};
export default integrations;

14
src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import routes from '@/routes'
import integrations from '@/integrations'
import type { App as Vue } from 'vue'
// CSS filename is injected by the vite plugin at build time
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { routes, integrations }
export default {
install(app: Vue) {
// Module-specific plugins can be registered here
}
}

9
src/routes.ts Normal file
View File

@@ -0,0 +1,9 @@
const routes = [
{
name: 'module_manager',
path: '/modules',
component: () => import('@/views/ModuleList.vue'),
},
];
export default routes;

View File

@@ -0,0 +1,40 @@
import type { Module, ModuleAction } from '@/types/module'
const API_BASE = '/modules'
export async function fetchModules(): Promise<Module[]> {
const response = await fetch(`${API_BASE}/list`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
})
if (!response.ok) {
throw new Error(`Failed to fetch modules: ${response.statusText}`)
}
const data = await response.json()
return data.modules || []
}
export async function manageModule(handle: string, action: ModuleAction): Promise<{ message?: string; error?: string }> {
const formData = new FormData()
formData.append('handle', handle)
formData.append('action', action)
const response = await fetch(`${API_BASE}/manage`, {
method: 'POST',
body: formData,
credentials: 'include',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || `Failed to ${action} module`)
}
return data
}

1
src/style.css Normal file
View File

@@ -0,0 +1 @@
/* module_manager module styles */

11
src/types/module.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface Module {
handle: string
label: string
author: string
description: string
version: string
installed: boolean
enabled: boolean
}
export type ModuleAction = 'install' | 'uninstall' | 'enable' | 'disable' | 'upgrade'

302
src/views/ModuleList.vue Normal file
View File

@@ -0,0 +1,302 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { fetchModules, manageModule } from '@/services/moduleService'
import type { Module, ModuleAction } from '@/types/module'
import {
mdiPackageVariant,
mdiDownload,
mdiTrashCan,
mdiCheckCircle,
mdiCloseCircle,
mdiUpdate,
mdiLoading,
} from '@mdi/js'
const modules = ref<Module[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const actionLoading = ref<string | null>(null)
const snackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
const search = ref('')
const filterStatus = ref<'all' | 'installed' | 'not-installed' | 'enabled' | 'disabled'>('all')
const filteredModules = computed(() => {
let result = modules.value
// Filter by status
if (filterStatus.value === 'installed') {
result = result.filter(m => m.installed)
} else if (filterStatus.value === 'not-installed') {
result = result.filter(m => !m.installed)
} else if (filterStatus.value === 'enabled') {
result = result.filter(m => m.enabled)
} else if (filterStatus.value === 'disabled') {
result = result.filter(m => m.installed && !m.enabled)
}
// Filter by search
if (search.value) {
const searchLower = search.value.toLowerCase()
result = result.filter(
m =>
m.handle.toLowerCase().includes(searchLower) ||
m.label.toLowerCase().includes(searchLower) ||
m.description.toLowerCase().includes(searchLower) ||
m.author.toLowerCase().includes(searchLower)
)
}
return result
})
async function loadModules() {
loading.value = true
error.value = null
try {
modules.value = await fetchModules()
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load modules'
} finally {
loading.value = false
}
}
async function handleAction(module: Module, action: ModuleAction) {
const key = `${module.handle}-${action}`
actionLoading.value = key
try {
const result = await manageModule(module.handle, action)
snackbarText.value = result.message || `Module ${action}ed successfully`
snackbarColor.value = 'success'
snackbar.value = true
// Reload modules to get updated state
await loadModules()
} catch (e) {
snackbarText.value = e instanceof Error ? e.message : `Failed to ${action} module`
snackbarColor.value = 'error'
snackbar.value = true
} finally {
actionLoading.value = null
}
}
function getStatusColor(module: Module): string {
if (!module.installed) return 'grey'
if (module.enabled) return 'success'
return 'warning'
}
function getStatusText(module: Module): string {
if (!module.installed) return 'Not Installed'
if (module.enabled) return 'Enabled'
return 'Disabled'
}
function isActionLoading(module: Module, action: string): boolean {
return actionLoading.value === `${module.handle}-${action}`
}
onMounted(() => {
loadModules()
})
</script>
<template>
<v-container fluid>
<v-row>
<v-col cols="12">
<v-card>
<v-card-title class="d-flex align-center">
<v-icon :icon="mdiPackageVariant" class="mr-2"></v-icon>
<span>Module Manager</span>
<v-spacer></v-spacer>
<v-btn
color="primary"
variant="text"
:loading="loading"
@click="loadModules"
>
Refresh
</v-btn>
</v-card-title>
<v-card-text>
<v-row class="mb-4">
<v-col cols="12" md="8">
<v-text-field
v-model="search"
label="Search modules"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
clearable
hide-details
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="filterStatus"
:items="[
{ title: 'All Modules', value: 'all' },
{ title: 'Installed', value: 'installed' },
{ title: 'Not Installed', value: 'not-installed' },
{ title: 'Enabled', value: 'enabled' },
{ title: 'Disabled', value: 'disabled' },
]"
label="Filter by status"
variant="outlined"
density="compact"
hide-details
></v-select>
</v-col>
</v-row>
<v-alert v-if="error" type="error" class="mb-4">
{{ error }}
</v-alert>
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<v-row v-else>
<v-col
v-for="module in filteredModules"
:key="module.handle"
cols="12"
md="6"
lg="4"
>
<v-card elevation="2" class="module-card">
<v-card-title class="d-flex align-center">
<v-icon :icon="mdiPackageVariant" class="mr-2"></v-icon>
<span>{{ module.label }}</span>
<v-spacer></v-spacer>
<v-chip
:color="getStatusColor(module)"
size="small"
variant="flat"
>
{{ getStatusText(module) }}
</v-chip>
</v-card-title>
<v-card-subtitle>
<div class="text-caption">{{ module.handle }}</div>
<div class="text-caption text-grey">by {{ module.author }} v{{ module.version }}</div>
</v-card-subtitle>
<v-card-text>
<p class="module-description">{{ module.description }}</p>
</v-card-text>
<v-card-actions>
<v-btn
v-if="!module.installed"
color="primary"
variant="tonal"
size="small"
:loading="isActionLoading(module, 'install')"
@click="handleAction(module, 'install')"
>
<v-icon :icon="mdiDownload" start></v-icon>
Install
</v-btn>
<template v-else>
<v-btn
v-if="module.enabled"
color="warning"
variant="tonal"
size="small"
:loading="isActionLoading(module, 'disable')"
@click="handleAction(module, 'disable')"
>
<v-icon :icon="mdiCloseCircle" start></v-icon>
Disable
</v-btn>
<v-btn
v-else
color="success"
variant="tonal"
size="small"
:loading="isActionLoading(module, 'enable')"
@click="handleAction(module, 'enable')"
>
<v-icon :icon="mdiCheckCircle" start></v-icon>
Enable
</v-btn>
<v-btn
color="info"
variant="tonal"
size="small"
:loading="isActionLoading(module, 'upgrade')"
@click="handleAction(module, 'upgrade')"
>
<v-icon :icon="mdiUpdate" start></v-icon>
Upgrade
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="error"
variant="tonal"
size="small"
:loading="isActionLoading(module, 'uninstall')"
@click="handleAction(module, 'uninstall')"
>
<v-icon :icon="mdiTrashCan" start></v-icon>
Uninstall
</v-btn>
</template>
</v-card-actions>
</v-card>
</v-col>
<v-col v-if="filteredModules.length === 0" cols="12">
<v-alert type="info">
No modules found matching your criteria.
</v-alert>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-snackbar
v-model="snackbar"
:color="snackbarColor"
:timeout="3000"
>
{{ snackbarText }}
</v-snackbar>
</v-container>
</template>
<style scoped lang="scss">
.module-card {
height: 100%;
display: flex;
flex-direction: column;
.v-card-text {
flex: 1;
}
.module-description {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 4.5em;
}
}
</style>

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

14
tsconfig.app.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["src/**/*", "src/**/*.vue"],
"exclude": ["node_modules"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@KTXC/*": ["../../core/src/*"]
}
}
}

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["src/**/*", "src/**/*.vue"],
"exclude": ["node_modules"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@KTXC/*": ["../../core/src/*"]
}
}
}

18
tsconfig.node.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

57
vite.config.ts Normal file
View File

@@ -0,0 +1,57 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
{
name: 'inject-css-filename',
enforce: 'post',
generateBundle(_options, bundle) {
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
if (!cssFile) return
for (const fileName of Object.keys(bundle)) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`)
}
}
}
}
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@KTXC': path.resolve(__dirname, '../../core/src')
},
},
define: {
'process.env': {},
'process': undefined,
},
build: {
outDir: 'static',
emptyOutDir: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'src/main.ts'),
formats: ['es'],
fileName: () => 'module.mjs',
},
rollupOptions: {
external: ['vue', 'vue-router', 'pinia'],
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.css')) {
return 'module_manager-[hash].css'
}
return '[name]-[hash][extname]'
}
}
},
},
})