62 |
63 |
--------------------------------------------------------------------------------
/docs/integrations/onlyoffice.md:
--------------------------------------------------------------------------------
1 | # ONLYOFFICE Integration
2 |
3 | Use ONLYOFFICE Document Server to edit office files (DOCX, XLSX, PPTX, ODT, ODS, ODP) from within nextExplorer. The integration relies on server-to-server API calls and a shared JWT secret.
4 |
5 | ## Environment variables
6 |
7 | | Variable | Required? | Description |
8 | | --- | --- | --- |
9 | | `ONLYOFFICE_URL` | Yes | Public URL of your Document Server (e.g., `https://office.example.com`). |
10 | | `PUBLIC_URL` | Yes | nextExplorer’s public URL so ONLYOFFICE knows where to download files and post callbacks. |
11 | | `ONLYOFFICE_SECRET` | Yes | JWT secret shared between nextExplorer and ONLYOFFICE for signing requests/responses. |
12 | | `ONLYOFFICE_LANG` | No (default `en`) | Language code for the editor UI. |
13 | | `ONLYOFFICE_FORCE_SAVE` | No | When true, users must use the editor’s Save button rather than relying on autosave. |
14 | | `ONLYOFFICE_FILE_EXTENSIONS` | No | Comma-separated list of extensions you want to surface beyond the defaults. |
15 |
16 | ## How it works
17 |
18 | 1. Opening a compatible file triggers a call to `/api/onlyoffice/config`, which returns editor configuration and a signed `config.token` when `ONLYOFFICE_SECRET` is set.
19 | 2. ONLYOFFICE fetches the file through `/api/onlyoffice/file?path=...` with an `Authorization: Bearer ` header.
20 | 3. After editing, ONLYOFFICE posts to `/api/onlyoffice/callback?path=...`, again authorized with the token; nextExplorer saves the changes automatically.
21 |
22 | ## Security notes
23 |
24 | - Tokens are signed with HS256 using `ONLYOFFICE_SECRET`. Keep this secret in sync with the Document Server’s `services.CoAuthoring.secret` (`local.json`).
25 | - To inspect the secret, run inside the Document Server container:
26 | ```bash
27 | jq -r '.services.CoAuthoring.secret.session.string' /etc/onlyoffice/documentserver/local.json
28 | ```
29 | - Disable ONLYOFFICE JWT on the Document Server only if you completely trust the network; otherwise, mismatched tokens result in “document security token is not correctly configured.”
30 |
--------------------------------------------------------------------------------
/frontend/src/composables/clipboardShortcuts.js:
--------------------------------------------------------------------------------
1 | import { useMagicKeys, whenever } from '@vueuse/core'
2 | import { computed } from 'vue'
3 | import { useFileActions } from '@/composables/fileActions'
4 | import { useDeleteConfirm } from '@/composables/useDeleteConfirm';
5 |
6 | export function useClipboardShortcuts() {
7 | const actions = useFileActions();
8 | const keys = useMagicKeys();
9 |
10 | // Small helper: ignore when focus is inside editable elements (inputs, textareas, contenteditable)
11 | const shouldIgnore = () => {
12 | const active = document.activeElement;
13 | return actions.isEditableElement ? actions.isEditableElement(active) : false;
14 | }
15 |
16 | const cutPressed = computed(() => (keys['Ctrl+X']?.value || keys['Meta+X']?.value));
17 | whenever(cutPressed, () => {
18 | if (shouldIgnore()) return;
19 | if (!actions.canCut.value) return;
20 | actions.runCut();
21 | });
22 |
23 | const copyPressed = computed(() => (keys['Ctrl+C']?.value || keys['Meta+C']?.value));
24 | whenever(copyPressed, () => {
25 | if (shouldIgnore()) return;
26 | if (!actions.canCopy.value) return;
27 | actions.runCopy();
28 | });
29 |
30 | const pastePressed = computed(() => (keys['Ctrl+V']?.value || keys['Meta+V']?.value));
31 | whenever(pastePressed, async () => {
32 | if (shouldIgnore()) return;
33 | if (!actions.canPaste.value) return;
34 | try {
35 | await actions.runPasteIntoCurrent();
36 | } catch (err) {
37 | // Surface errors to console only; UI can handle feedback separately
38 | console.error('Paste failed', err);
39 | }
40 | });
41 |
42 | // Delete / Backspace -> delete selected items (when focus not in editable)
43 | const { requestDelete } = useDeleteConfirm();
44 |
45 | const deletePressed = computed(() => (keys.delete?.value || keys.backspace?.value));
46 | whenever(deletePressed, async () => {
47 | if (shouldIgnore()) return;
48 | // Open the centralized delete confirmation dialog instead of deleting immediately
49 | requestDelete();
50 | });
51 | }
52 |
--------------------------------------------------------------------------------
/docs/configuration/settings.md:
--------------------------------------------------------------------------------
1 | # Runtime Settings
2 |
3 | In-app settings expose many server-side toggles you’ll also find in the environment reference. Admin sections unlock once your user has the `admin` role or matches `OIDC_ADMIN_GROUPS`.
4 |
5 | ## Files & Thumbnails
6 |
7 | - **Enable thumbnails:** Toggle thumbnail generation (uses Sharp/FFmpeg). Disable to reduce CPU usage when browsing large volumes.
8 | - **Thumbnail quality:** 1–100 (default 70) to control JPEG compression level.
9 | - **Max dimension:** Longest side in pixels (default 200) for generated thumbnails.
10 | - **Video previews:** Require FFmpeg/ffprobe; binaries are included but you can override paths via environment variables.
11 |
12 | ## Security & Authentication
13 |
14 | - **Authentication toggle:** Turn on/off authentication for trusted, internal networks (not recommended for public deployments).
15 | - **OIDC configuration:** The fields here mirror the environment variables in the reference section. Use them to enable SSO once you’ve configured your IdP.
16 | - **Session locking:** Enable/disable workspace password prompts that gate the entire UI.
17 |
18 | ## Access Control
19 |
20 | - **Rule editor:** Define per-folder rules with `path`, `type` (`rw`, `ro`, `hidden`), and recursion options.
21 | - **First-match wins:** Rules are evaluated top to bottom; the first matching path governs browser behavior.
22 | - **Hidden folders:** Use `hidden` to keep folders out of listings while still accessible via direct URLs.
23 |
24 | ## Admin Users
25 |
26 | - **Create or edit local users:** Manage usernames, passwords, and roles (user vs admin).
27 | - **Password reset:** Reset passwords for local accounts without needing direct OS access.
28 | - **Admin safeguarding:** The UI prevents demoting or deleting the last admin to avoid lockouts.
29 |
30 | ## Additional hints
31 |
32 | - Most settings persist in `/config/app-config.json`. Back up `/config` before making sweeping changes.
33 | - Favorites and access control settings sync with the sidebar, so once you pin a favorite it surfaces immediately for all sessions.
34 |
--------------------------------------------------------------------------------
/frontend/src/plugins/audio/AudioPreview.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Audio
7 |
8 |
9 | {{ item?.name || '—' }}
10 |
11 |
12 |
13 |
24 |
25 |
26 | Preview unavailable.
27 |
28 |
29 |
30 |
31 |
32 |
72 |
73 |
--------------------------------------------------------------------------------
/frontend/src/composables/useFileDialog.js:
--------------------------------------------------------------------------------
1 | import { ref, onMounted, onBeforeUnmount } from "vue";
2 |
3 | const defaultDialogOptions = {
4 | multiple: true,
5 | accept: '*',
6 | directory: true,
7 | };
8 |
9 | export function useFileDialog() {
10 | const inputRef = ref(null);
11 | const files = ref([]);
12 |
13 | onMounted(() => {
14 | const input = document.createElement("input");
15 | input.type = "file";
16 | input.className = "hidden";
17 | document.body.appendChild(input);
18 | inputRef.value = input;
19 | });
20 |
21 | onBeforeUnmount(() => {
22 | inputRef.value?.remove();
23 | });
24 |
25 | function openFileDialog(opts) {
26 | return new Promise((resolve) => {
27 | if (!inputRef.value) {
28 | return;
29 | }
30 | files.value = [];
31 |
32 | const options = { ...defaultDialogOptions, ...opts };
33 | inputRef.value.accept = options.accept;
34 | inputRef.value.multiple = options.multiple;
35 |
36 | if (options.directory) {
37 | inputRef.value.webkitdirectory = !!options.directory;
38 | inputRef.value.directory = !!options.directory;
39 | inputRef.value.mozdirectory = !!options.directory;
40 | }
41 |
42 | inputRef.value.onchange = (e) => {
43 | if (options.directory) {
44 | // Process directory files
45 | const items = Array.from(e.target.files);
46 | const directories = {};
47 | items.forEach(file => {
48 | const path = file.webkitRelativePath.split('/');
49 | const dir = path[0];
50 | if (!directories[dir]) {
51 | directories[dir] = [];
52 | }
53 | directories[dir].push(file);
54 | });
55 | files.value = directories;
56 | } else {
57 | // Process individual files
58 | files.value = Array.from(e.target.files);
59 | }
60 | resolve();
61 | };
62 |
63 | inputRef.value.click();
64 | });
65 | }
66 |
67 | return {
68 | openFileDialog,
69 | files,
70 | };
71 | }
72 |
--------------------------------------------------------------------------------
/backend/src/services/storage/jsonStorage.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs/promises');
2 | const { directories, files } = require('../../config/index');
3 | const { ensureDir } = require('../../utils/fsUtils');
4 | const logger = require('../../utils/logger');
5 |
6 | const CONFIG_FILE = files.passwordConfig;
7 | const ENCODING = 'utf8';
8 |
9 | let cache = null;
10 | let initialized = false;
11 |
12 | /**
13 | * Default structure for app-config.json
14 | */
15 | const DEFAULT_DATA = {
16 | version: 4,
17 | settings: {
18 | thumbnails: { enabled: true, size: 200, quality: 70 },
19 | access: { rules: [] },
20 | },
21 | favorites: [],
22 | };
23 |
24 | /**
25 | * Ensure config directory and file exist
26 | */
27 | const init = async () => {
28 | if (initialized) return;
29 |
30 | await ensureDir(directories.config);
31 |
32 | try {
33 | await fs.access(CONFIG_FILE);
34 | } catch (error) {
35 | if (error?.code === 'ENOENT') {
36 | logger.info('Creating default config file');
37 | await fs.writeFile(CONFIG_FILE, JSON.stringify(DEFAULT_DATA, null, 2) + '\n', ENCODING);
38 | }
39 | }
40 |
41 | cache = await read();
42 | initialized = true;
43 | };
44 |
45 | /**
46 | * Read from disk
47 | */
48 | const read = async () => {
49 | try {
50 | const raw = await fs.readFile(CONFIG_FILE, ENCODING);
51 | return JSON.parse(raw);
52 | } catch (error) {
53 | logger.warn({ err: error }, 'Failed to read config, using defaults');
54 | return { ...DEFAULT_DATA };
55 | }
56 | };
57 |
58 | /**
59 | * Write to disk and update cache
60 | */
61 | const write = async (data) => {
62 | await fs.writeFile(CONFIG_FILE, JSON.stringify(data, null, 2) + '\n', ENCODING);
63 | cache = data;
64 | return data;
65 | };
66 |
67 | /**
68 | * Get entire config
69 | */
70 | const get = async () => {
71 | await init();
72 | return JSON.parse(JSON.stringify(cache)); // Deep clone
73 | };
74 |
75 | /**
76 | * Update config with an updater function
77 | */
78 | const update = async (updater) => {
79 | await init();
80 | const current = await get();
81 | const next = updater(current);
82 | return write(next);
83 | };
84 |
85 | module.exports = { get, update };
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | app:
3 |
4 | build:
5 | context: .
6 | dockerfile: Dockerfile
7 | ports:
8 | - 3000:3000
9 | volumes:
10 | - /Users/vikram/projects:/mnt/Projects
11 | - /Users/vikram/Downloads:/mnt/Downloads
12 | - /Users/vikram/Downloads/config:/config
13 | - /Users/vikram/Downloads/cache:/cache
14 | environment:
15 | - LOG_LEVEL=debug
16 | - DEBUG=openid-client*
17 |
18 | # Match these to your host user/group IDs (check with `id -u` / `id -g`)
19 | - PUID=1000
20 | - PGID=1000
21 | # Centralized public URL (frontend + backend behind proxy)
22 | - PUBLIC_URL=https://files.vsoni.com
23 |
24 |
25 | # OIDC configuration (fill with your identity provider details)
26 | - OIDC_ENABLED=true
27 | - OIDC_ISSUER=https://auth.vsoni.com/application/o/next/
28 | - OIDC_CLIENT_ID=6HBh3lMneUwWCAeutdEgwNZ6myXzFc93clojdfmQ
29 | - OIDC_CLIENT_SECRET=w2ACQfUhhk2vZ14m2GVvYbWqkCQTiXyMsowpLQE2JtiPfaCTrBKhZHwZACgHOCWgSs9zhJW0j8y1TYCrLs0hsMmM0xdIz5tRmce6o4ymVOCQByPRIr9xMxtBt1Spg0I1
30 | - OIDC_SCOPES=openid,profile,email,groups
31 | - OIDC_ADMIN_GROUPS=next-admin,admins
32 |
33 | - ONLYOFFICE_URL=https://office.vsoni.com
34 | - ONLYOFFICE_SECRET=ha96M546Hl5oSSvrjvabmXK1D6qlRH15
35 | - ONLYOFFICE_LANG=en
36 | - ONLYOFFICE_FILE_EXTENSIONS=doc,docx,xls,xlsx,ppt,pptx,odt,ods,odp,rtf,txt,pdf
37 |
38 | # Authentication configuration
39 | # AUTH_MODE: Controls which authentication methods are available
40 | # - 'local': Only username/password authentication
41 | # - 'oidc': Only OIDC/SSO authentication
42 | # - 'both': Both local and OIDC (default if not specified)
43 | # - 'disabled': Skip authentication entirely (same as AUTH_ENABLED=false)
44 | # - AUTH_MODE=disabled
45 |
46 | # Legacy auth toggle (deprecated, use AUTH_MODE=disabled instead)
47 | # - AUTH_ENABLED=false
48 |
49 | # Optional: bootstrap the first local admin (skips setup wizard)
50 | # - AUTH_ADMIN_EMAIL=admin@example.com
51 | # - AUTH_ADMIN_PASSWORD=please-change-me
52 |
53 | restart: unless-stopped
54 |
--------------------------------------------------------------------------------
/frontend/src/assets/base.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 | @plugin "@tailwindcss/typography";
3 |
4 | @custom-variant dark (&:where(.dark, .dark *));
5 |
6 |
7 | /* Themed, thin scrollbars (global) */
8 | @layer base {
9 | /* Default (light) theme variables */
10 | :root {
11 | --scrollbar-track: transparent; /* nextgray-200 */
12 | --scrollbar-thumb: #b7b4b4; /* nextgray-400 */
13 | --scrollbar-thumb-hover: #BDBDBD;
14 |
15 | --clr-base: #fff;
16 | --clr-base-muted: #f9f9f9;
17 | }
18 |
19 | .dark {
20 | --scrollbar-track: transparent; /* nextslate-900 */
21 | --scrollbar-thumb: #3d3d3f; /* nextgray-700 */
22 | --scrollbar-thumb-hover: #3a3a3d;
23 |
24 | --clr-base: #212121;
25 | --clr-base-muted: #181818;
26 | }
27 |
28 | *,
29 | ::after,
30 | ::before,
31 | ::backdrop,
32 | ::file-selector-button {
33 | border-color: var(--color-gray-200, currentcolor);
34 | }
35 |
36 | /* Firefox */
37 | html {
38 | scrollbar-width: auto;
39 | scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
40 | }
41 |
42 | /* WebKit (Chrome, Edge, Safari) */
43 | *::-webkit-scrollbar {
44 | width: 10px;
45 | height: 8px;
46 | }
47 | *::-webkit-scrollbar-track {
48 | background: var(--scrollbar-track);
49 | }
50 | *::-webkit-scrollbar-thumb {
51 | background-color: var(--scrollbar-thumb);
52 | border-radius: 9999px;
53 | border: 2px solid var(--scrollbar-track);
54 | }
55 | *::-webkit-scrollbar-thumb:hover {
56 | background-color: var(--scrollbar-thumb-hover);
57 | }
58 | }
59 |
60 | @layer components {
61 | /* Make vue-drag-select behave like its “Drag To Scroll” story:
62 | root wrapper is the scroll container, and the inner
63 | .drag-select area fills its height so you can start a drag
64 | anywhere in the visible region (even with few items). */
65 | .drag-select__wrapper {
66 | @apply h-full;
67 | }
68 |
69 | .drag-select {
70 | @apply min-h-full;
71 | }
72 | }
73 |
74 |
75 |
76 | @theme {
77 | /* Avoid collision with Tailwind's built-in `text-base` font-size utility. */
78 | --color-default: var(--clr-base);
79 | --color-default-muted: var(--clr-base-muted);
80 | }
81 |
--------------------------------------------------------------------------------
/frontend/src/composables/useCodemirror.js:
--------------------------------------------------------------------------------
1 | import { markRaw, onMounted, reactive, toRefs, unref } from "vue";
2 |
3 | import CodeMirror from "codemirror";
4 | import "codemirror/mode/htmlmixed/htmlmixed";
5 | import "codemirror/lib/codemirror.css";
6 | import "codemirror/theme/monokai.css";
7 | import "codemirror/theme/dracula.css";
8 | import "codemirror/theme/ambiance.css";
9 | import "codemirror/theme/material.css";
10 | import "codemirror/addon/lint/lint";
11 | import "codemirror/addon/lint/lint.css";
12 |
13 | export default function useCodeMirror({
14 | emit,
15 | initialValue = "",
16 | options = {},
17 | textareaRef
18 | }) {
19 | const state = reactive({
20 | cm: null,
21 | dirty: null,
22 | generation: null
23 | });
24 |
25 | const hasErrors = () => !!state.cm.state.lint.marked.length;
26 |
27 | const setValue = (val) => {
28 | state.cm.setValue(val);
29 | state.generation = state.cm.doc.changeGeneration(true);
30 | state.cm.markClean();
31 | state.dirty = false;
32 | };
33 |
34 | const append = (val) => {
35 | state.cm.doc.replaceRange(val, { line: Infinity });
36 | };
37 |
38 | const initialize = () => {
39 |
40 | // CodeMirror.registerHelper("lint", options.mode, function (text) {
41 | // return options.lint.getAnnotations;
42 | // });
43 |
44 | // create code mirror instance
45 | state.cm = markRaw(
46 | CodeMirror.fromTextArea(textareaRef.value, {
47 | ...unref(options)
48 | })
49 | );
50 |
51 | // initialize editor with initial value
52 | state.cm.setValue(initialValue);
53 |
54 | // mark clean and prep for change tracking
55 | state.generation = state.cm.doc.changeGeneration(true);
56 | state.dirty = !state.cm.doc.isClean(state.generation);
57 |
58 | // synchronize with state (if dirty)
59 | state.cm.on("change", (cm) => {
60 | state.dirty = !state.cm.doc.isClean(state.generation);
61 | emit("change", { value: cm.getValue() });
62 | });
63 | };
64 |
65 | onMounted(() => initialize());
66 |
67 | const { cm: editor, ...rest } = toRefs(state);
68 |
69 | return {
70 | ...rest,
71 | append,
72 | editor,
73 | hasErrors,
74 | setValue
75 | };
76 | }
77 |
--------------------------------------------------------------------------------
/docs/reference/faq.md:
--------------------------------------------------------------------------------
1 | # FAQ
2 |
3 | ## What do I need before installing?
4 | - Docker Engine 24+ and Docker Compose v2.
5 | - Host folders to mount under `/mnt` and persistent storage for `/config` (back it up) plus optional `/cache`.
6 | - Optional environment variables for your preferred authentication, reverse proxy, and feature toggles; see the [Environment Reference](../configuration/environment) for the full list.
7 |
8 | ## How do I unlock the workspace after first setup?
9 | The Setup screen creates the first admin (unless you bootstrap one via `AUTH_ADMIN_EMAIL`/`AUTH_ADMIN_PASSWORD`). Once the workspace password is set, use that login to add local users or configure OIDC. Admin-only settings live under Settings → Admin.
10 |
11 | ## Where do I troubleshoot deployment issues?
12 | Check the [Troubleshooting](./troubleshooting) page for proxy/CORS tips, session secret advice, volume permissions, and thumbnail/search behavior.
13 |
14 | ## How can I keep my deployment updated?
15 | The app stores persistent state in the `/config` bind mount. Back up `/config/app-config.json` and `/config/app.db` before updating. Run `docker compose pull` and `docker compose up -d` to refresh the image, then verify volumes and settings in the UI.
16 |
17 | ## Who handles metadata and search indexing?
18 | Thumbnails and ripgrep backed search results live in `/cache`. You can clear/recreate this mount without losing settings. If thumbnails aren't appearing, ensure FFmpeg/ffprobe are available (provided in the official image) and `FFMPEG_PATH`/`FFPROBE_PATH` point to valid binaries.
19 |
20 | ## How do I add support for custom file types in the editor?
21 | The inline editor supports 50+ file types by default (txt, md, json, js, ts, py, yml, html, css, and many more). To add support for additional file extensions at runtime, set the `EDITOR_EXTENSIONS` environment variable with a comma-separated list:
22 |
23 | ```yaml
24 | environment:
25 | - EDITOR_EXTENSIONS=toml,proto,graphql,dockerfile,makefile
26 | ```
27 |
28 | Custom extensions are **added to** the default list (they don't replace it), and changes take effect immediately on container restart—no frontend rebuild required. See the [Environment Reference](../configuration/environment#editor) for details.
29 |
--------------------------------------------------------------------------------
/frontend/src/components/TerminalMenu.vue:
--------------------------------------------------------------------------------
1 |
20 |
21 |
22 |
19 |
20 |
21 |
22 |
63 |
64 |
70 |
--------------------------------------------------------------------------------
/docs/admin/user-volumes.md:
--------------------------------------------------------------------------------
1 | # User volumes (per-user volume assignments)
2 |
3 | When `USER_VOLUMES=true`, nextExplorer stops showing *all* mounted volumes to everyone. Admins still see all volumes under `VOLUME_ROOT`, but regular users only see volumes explicitly assigned to them by an admin.
4 |
5 | ## Enable the feature
6 |
7 | Set the environment variable and restart the container:
8 |
9 | ```bash
10 | USER_VOLUMES=true
11 | ```
12 |
13 | ## What changes when enabled
14 |
15 | - **Admins**: continue to see all volumes mounted under `VOLUME_ROOT` (default `/mnt`).
16 | - **Non-admin users**: the root volume list is filtered down to only the volumes assigned to their user profile.
17 | - **No assignments = no volumes**: if a user has no assigned volumes, they’ll see an empty volume list.
18 |
19 | ## Assign volumes to a user (admin)
20 |
21 | 1. Go to **Settings → Admin → Users**.
22 | 2. Click a user (or create a new one).
23 | 3. Open the **Volumes** tab.
24 | 4. Click **Add volume**, browse to a directory, choose an access mode, and save.
25 |
26 | 
27 |
28 | 
29 |
30 | ### Volume fields
31 |
32 | - **Label**: the name the user sees in the sidebar (must be unique per user).
33 | - **Directory**: an existing directory path on the server/container (must be readable by the container user).
34 | - **Access mode**:
35 | - `readwrite`: user can upload, create folders, rename, move, and delete.
36 | - `readonly`: user can browse and download, but cannot modify content.
37 |
38 |
39 | ## Notes & interactions
40 |
41 | - **Directory picker**: the admin directory browser starts at `VOLUME_ROOT`, hides dot-directories, and excludes reserved names like `_users`.
42 | - **Access Control still applies**: if Access Control marks a path `ro` or `hidden`, that restriction also applies on top of the user volume assignment.
43 | - **Persistence**: assignments are stored in the SQLite DB under `CONFIG_DIR` (`/config` by default).
44 |
45 | ## Troubleshooting
46 |
47 | - **Volumes tab missing**: confirm `USER_VOLUMES=true` and refresh the app (feature flags are loaded from `/api/features`).
48 | - **User can’t see a volume**: verify the user has an assigned volume, and that the chosen label matches what they’re navigating to.
49 | - **“Path does not exist or is not accessible”**: the server process inside the container can’t `stat()` the directory; fix mount/permissions and try again.
50 |
--------------------------------------------------------------------------------
/frontend/src/components/NotificationToast.vue:
--------------------------------------------------------------------------------
1 |
39 |
40 |
41 |