Appearance
Develop Plugins
Learn how to create plugins for Motanamy platforms (EA, SA, OS).
Quick Start
1. Install CLI
bash
npm install -g motanamy-cli2. Create Plugin
bash
mot create my-plugin --type EA # EA, SA, or OS
cd my-plugin3. Develop
Edit 1.0.0/main.js for backend logic and files in 1.0.0/src/ for frontend components.
4. Test
bash
mot run5. Build & Upload
bash
mot build
mot uploadPlugin Types
- EA Plugins: Web extensions for Enterprise Applications
- SA Plugins: UI extensions for Standalone Applications
- OS Plugins: System-level extensions
Basic Structure
my-plugin/
├── 1.0.0/
│ ├── settings.json # Version-specific settings (manifest)
│ ├── script.js # Main plugin script for frontend loading
│ ├── README-AR.md # Arabic documentation
│ ├── README-EN.md # English documentation
│ ├── src/ # Frontend source code
│ ├── main.js # Backend logic (connect to nestjs/express)
│ ├── backend/ # Express routes if needed
│ └── styles/ # Version-specific styles
├── 1.1.0/
│ ├── settings.json # Updated settings for v1.1.0
│ ├── script.js # Updated script for v1.1.0
│ ├── README-AR.md # Updated Arabic docs
│ ├── README-EN.md # Updated English docs
│ ├── src/ # Updated frontend source code
│ ├── main.js # Updated backend logic
│ ├── backend/ # Updated express routes
│ └── styles/ # Updated styles
├── database/
│ ├── dist/ # Custom landing page or plugin
│ ├── assets/ # Database assets
│ ├── images/ # Database images
│ ├── filesystem.json # Database filesystem
│ └── backend/ # NestJS outputVersion Folder Structure
Each version folder contains version-specific files:
1.0.0/
├── settings.json # App configuration (manifest) for this version
├── script.js # Main plugin script for frontend loading
├── README-AR.md # Arabic documentation
├── README-EN.md # English documentation
├── src/ # Frontend source code
│ ├── components/ # Vue.js components with PrimeVue
│ ├── views/ # Page/view components
│ ├── services/ # API services and business logic
│ ├── utils/ # Utility functions
│ └── config/ # Configuration files
├── main.js # Backend logic (connect to nestjs/express/microservices)
├── backend/ # Express routes if needed
└── styles/ # Version-specific stylesDatabase Folder Structure
database/
├── dist/ # Custom landing page or plugin
├── assets/ # Database assets
├── images/ # Database images
├── filesystem.json # Database filesystem configuration
└── backend/ # NestJS output and backend filesReal-World Implementation Examples
File System Database Operations
Here's a practical example of how to work with the filesystem database in Motanamy plugins, based on real implementation patterns:
html
<!-- src/components/BannerManager.vue -->
<template>
<div class="banner-manager">
<div class="card flex align-items-center justify-content-between">
<h5 class="m-0">Banner Management</h5>
<Button
label="Save"
icon="pi pi-save"
class="p-button-text"
@click="save()"
/>
</div>
<div class="card">
<Button
icon="pi pi-plus"
type="button"
class="p-button-text"
@click="addBanner"
/>
<div
v-for="(item, index) in banner"
:key="index"
class="banner-item mb-4 p-3 border-1 border-round"
>
<div class="flex align-items-center justify-content-between mb-3">
<h6 class="m-0">Banner {{ index + 1 }}</h6>
<div v-if="item.link" class="flex align-items-center gap-1">
<i class="pi pi-link text-primary"></i>
<small class="text-primary">Linked to Event</small>
</div>
</div>
<div class="grid">
<div class="col-12 md:col-6">
<label class="block mb-2 font-semibold">Banner Image</label>
<div class="p-inputgroup">
<InputText
v-model="banner[index].image"
readonly
placeholder="Select banner image"
class="w-full"
/>
<Button
icon="pi pi-check-circle"
class="p-button-primary"
@click="openFileExplorerToGetLogo(index)"
/>
</div>
</div>
<div class="col-12 md:col-6">
<label class="block mb-2 font-semibold">Event Link (Optional)</label>
<InputText
v-model="banner[index].link"
type="url"
placeholder="https://example.com/event"
class="w-full"
/>
</div>
</div>
<div class="flex justify-content-end mt-3">
<Button
icon="pi pi-trash"
v-tooltip.top="'Delete Banner'"
type="button"
class="p-button-text p-button-danger"
@click="removeBanner(index)"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from "axios";
import { EventBus } from "global";
import app from "app";
export default {
data() {
return {
banner: []
};
},
created() {
this.init();
},
methods: {
async init() {
const data = (
await axios.get(
`plugins/database?path=${["quran-suhba", "banner.json"].join(",")}`
)
).data;
// Handle backward compatibility - convert old string format to new object format
this.banner = data.map(item => {
if (typeof item === 'string') {
return { image: item, link: '' };
}
return item;
});
},
addBanner() {
this.banner.push({ image: '', link: '' });
},
removeBanner(index) {
this.banner.splice(index, 1);
},
openFileExplorerToGetLogo(index) {
EventBus.emit("openFileExplorer", {
type: "file",
exe: [],
callback: ele => {
this.banner[index].image = [...app.role.fileExplorer, ...ele.path].join(
","
);
}
});
},
async save() {
// Validate URLs before saving
const invalidUrls = this.banner.filter(item =>
item.link && !this.isValidUrl(item.link)
);
if (invalidUrls.length > 0) {
this.$toast.add({
severity: "error",
summary: "Invalid URLs",
detail: "Please enter valid URLs for event links"
});
return;
}
await axios.post(
`plugins/database?path=${["quran-suhba", "banner.json"].join(",")}`,
{ data: this.banner }
);
this.$toast.add({
severity: "success",
summary: "Banners",
detail: "Saved successfully"
});
},
isValidUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
}
};
</script>
<style scoped>
.banner-item {
background-color: #f8f9fa;
border-color: #e9ecef;
}
.banner-item:hover {
background-color: #f1f3f4;
border-color: #dadce0;
}
.font-semibold {
font-weight: 600;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mt-3 {
margin-top: 0.75rem;
}
.p-3 {
padding: 0.75rem;
}
.border-1 {
border-width: 1px;
}
.border-round {
border-radius: 6px;
}
.text-primary {
color: #007bff;
}
.gap-1 {
gap: 0.25rem;
}
.m-0 {
margin: 0;
}
.w-full {
width: 100% !important;
}
</style>Key Points for Filesystem Database:
- Files are stored in the
database/folder of your plugin - Use
plugins/databaseendpoint with path parameter - Path format:
['plugin-name', 'filename.json'].join(',') - Handle backward compatibility for data format changes
- Use EventBus for file explorer integration
- Always validate data before saving
API Integration with Axios
Here's a complete example of API integration using axios for plugin data management:
html
<!-- src/components/UserList.vue -->
<template>
<div>
<DataTable :value="users" @row-click="edited($event.data.id)" :rows="20" :globalFilterFields="['type']"
v-model:filters="filters" data-key="id" v-model:selection="selectedUsers" filterDisplay="row"
:paginator="users.length > 20" responsiveLayout="scroll" class="card mb-2">
<template #header>
<div class="flex align-items-center justify-content-between">
Users
<div>
<Button icon="pi pi-plus" class="p-button-text" @click="edited()" />
<Button icon="pi pi-refresh" class="p-button-text" @click="init()" />
<Button v-tooltip.top="'Notifications'" icon="mdi mdi-bell-outline" :disabled="selectedUsers.length == 0"
class="p-button-text" @click="openNotification(selectedUsers)"></Button>
</div>
</div>
</template>
<Column selectionMode="multiple" headerStyle="width: 3rem"></Column>
<Column field="id" header="ID" :sortable="true"></Column>
<Column field="email" header="Email" :sortable="true">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" @input="filterCallback()" class="p-column-filter"
placeholder="Search by email" />
</template>
</Column>
<Column field="name" header="Name" :sortable="true">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" @input="filterCallback()" class="p-column-filter"
placeholder="Search by name" />
</template>
</Column>
<Column field="phone" header="Phone" :sortable="true"></Column>
<Column field="type" header="Type" :sortable="true" :showFilterMenu="false">
<template #body="{ data }">
<Chip :label="data.type" />
</template>
<template #filter="{ filterModel, filterCallback }">
<Dropdown v-model="filterModel.value" class="p-column-filter" style="min-width: 12rem"
@change="filterCallback()" :showClear="true" :options="['user', 'supervisor', 'admin', 'examer']">
<template #option="slotProps">
<Tag :value="slotProps.option" :severity="slotProps.option" />
</template>
</Dropdown>
</template>
</Column>
<Column field="organization.name" header="Organization" :sortable="true">
<template #filter="{ filterModel, filterCallback }">
<InputText v-model="filterModel.value" type="text" @input="filterCallback()" class="p-column-filter"
placeholder="Search by organization" />
</template>
</Column>
<Column field="verified" header="Verified" :sortable="true">
<template #body="{ data }">
<i :class="data.verified ? 'pi pi-check text-green-500' : 'pi pi-times text-red-500'"></i>
</template>
</Column>
<Column field="blocked" header="Blocked" :sortable="true">
<template #body="{ data }">
<i :class="data.blocked ? 'pi pi-ban text-red-500' : 'pi pi-check text-green-500'"></i>
</template>
</Column>
<Column field="wallet" header="Wallet" :sortable="true">
<template #body="{ data }">
{{ data.wallet || 0 }}
</template>
</Column>
<Column header="Actions">
<template #body="{ data }">
<div class="flex flex-row gap-2">
<Button v-tooltip.top="'Notifications'" @click="openNotification([data])" icon="mdi mdi-bell-outline"
type="button" class="p-button-text"></Button>
<Button icon="pi pi-search" v-tooltip.top="'Edited'" type="button" class="p-button-text"
@click="edited(data.id)"></Button>
<Button icon="pi pi-trash" v-tooltip.top="'Delete'" type="button" class="p-button-text p-button-danger"
@click="deleteElemet(data)"></Button>
</div>
</template>
</Column>
</DataTable>
<notifications ref="EditedDialog" />
</div>
</template>
<script>
import axios from "axios";
import notifications from "vue:quran-suhba/users/notification.vue";
export default {
components: {
notifications
},
data() {
return {
users: [],
selectedUsers: [],
filters: {
email: { value: null },
name: { value: null },
"organization.name": { value: null },
type: { value: null, matchMode: "equals" }
},
filter: {}
};
},
created() {
this.init();
},
methods: {
openNotification(data) {
this.$refs.EditedDialog.open(data);
},
edited(id = "add") {
this.$router.push(`/app/suhba/users/${id}`);
},
init() {
axios.get(`suhba/users`).then(res => {
this.users = res.data;
});
},
deleteElemet(data) {
this.$confirm.require({
header: "Confirmation",
message: "Are you sure you want to delete this user?",
acceptLabel: "Yes",
rejectLabel: "No",
acceptClass: "p-button-danger",
accept: () => {
axios
.delete(`suhba/users/${data.id}`)
.then(res => {
this.init();
this.$toast.add({
severity: "success",
summary: "Users",
detail: "User deleted successfully"
});
})
.catch(err => {
this.$toast.add({
severity: "error",
summary: "Users",
detail: "Failed to delete user"
});
});
}
});
}
}
};
</script>Key Points for API Integration:
- Use axios for HTTP requests to plugin endpoints
- Plugin endpoints follow the pattern:
plugin-name/endpoint - Handle loading states and error responses
- Use PrimeVue components for rich UI (DataTable, Button, etc.)
- Implement proper error handling with toast notifications
- Use confirmation dialogs for destructive actions
- Implement filtering and pagination for large datasets
Settings File (Manifest)
json
{
"versionNumber": "0", // Version counter (increments with updates)
"version": "1.0.0", // Semantic version string
"name": "my-plugin", // Plugin/app name
"init": true, // Whether plugin is initialized
"user": 1, // User ID who owns/created the plugin
"type": "EA", // Platform type: EA (Enterprise), SA (Standalone), OS (Operating System)
"main": "main.js", // Main entry point file
"assets": "assets", // Assets directory path
"script": "script.js", // Frontend script file
"id": "unique-plugin-id" // Unique plugin identifier
}Best Practices
- Keep plugins focused on single functionality
- Handle errors gracefully
- Test on all target platforms
- Follow security guidelines
- Document your plugin clearly