diff --git a/README.md b/README.md index f9276e4..de2937d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Solution Inventory

-Vue 3 + Vuetify application for documenting solution questionnaires across multiple projects. Available as both a Progressive Web App (PWA) and an Electron desktop application. It uses a project tree for navigation, questionnaire tabs for editing, and a configuration editor in a dialog. +Vue 3 + Vuetify application for documenting solution questionnaires across multiple projects. Available as both a Progressive Web App (PWA) and an Electron desktop application for **Windows** and **Linux**. The Electron app stores all data locally on the device (no cloud sync). The web version (hosted via GitHub Pages) stores all data exclusively in the browser's Local Storage (no server-side storage or sync). It uses a project tree for navigation, questionnaire tabs for editing, and a configuration editor in a dialog. ## Features - Project tree with create/rename/delete and drag-and-drop (move and reorder questionnaires) @@ -41,7 +41,8 @@ npm install npm run dev ``` -### Development (Electron) + +### Development (Electron, Windows & Linux) ```bash npm run electron:dev ``` @@ -51,7 +52,8 @@ npm run electron:dev npm run build ``` -### Production Build (Electron) + +### Production Build (Electron, Windows & Linux) ```bash npm run electron:build ``` @@ -61,7 +63,8 @@ npm run electron:build npm run preview ``` -### Preview Build (Electron) + +### Preview Build (Electron, Windows & Linux) ```bash npm run electron:preview ``` @@ -161,12 +164,19 @@ Use the menu (⋮) in the toolbar to: - Export as ThoughtWorks Build-Your-Own-Radar JSON format - Download radar visualization as PNG image + ## Project Import/Export - Export a single project from the project menu. - Import a project JSON using the import dialog in the project tree header. +## Data Storage + +- **Electron App (Windows & Linux):** All data is stored locally on your device. No data is sent to any server or cloud service. +- **Web App (GitHub Pages):** All data is stored exclusively in your browser's Local Storage. There is no server-side storage or synchronization. Clearing your browser data will remove all projects and questionnaires. + + ## GitHub Pages Deployment -GitHub Actions builds and deploys on push to main. Pushes and PRs to dev build but do not deploy. +GitHub Actions builds and deploys on push to main. The web version is hosted at GitHub Pages and stores all data only in the browser's Local Storage. Pushes and PRs to dev build but do not deploy. ## Dependencies - Vue 3 diff --git a/electron/main.js b/electron/main.js index 4ac3e3a..d22a8f0 100644 --- a/electron/main.js +++ b/electron/main.js @@ -64,6 +64,7 @@ function createWindow() { width: 1200, height: 800, show: false, + autoHideMenuBar: true, webPreferences: { nodeIntegration: false, contextIsolation: true, diff --git a/src/App.vue b/src/App.vue index bc89126..3d6cc86 100644 --- a/src/App.vue +++ b/src/App.vue @@ -17,7 +17,7 @@ - + mdi-database-cog Manage workspace @@ -190,6 +190,12 @@ export default { /* small global styles */ body { font-family: Roboto, Arial, sans-serif; } +/* Show scrollbar only when content overflows */ +html, body { overflow-y: auto !important; } +::-webkit-scrollbar { width: 8px; } +::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.35); } + .main-container { max-width: 1800px; margin: 0 auto; diff --git a/src/components/questionaire/Questionnaire.vue b/src/components/questionaire/Questionnaire.vue index 2fc735a..ca5edf4 100644 --- a/src/components/questionaire/Questionnaire.vue +++ b/src/components/questionaire/Questionnaire.vue @@ -33,6 +33,16 @@

{{ currentCategory.title }}

+ + mdi-eye-off-outline + {{ currentCategoryHiddenCount }} hidden +
+
+ + + {{ entrySort === 'asc' ? 'mdi-sort-alphabetical-ascending' : entrySort === 'desc' ? 'mdi-sort-alphabetical-descending' : 'mdi-sort-variant' }} + {{ entrySort === 'asc' ? 'Sorted A→Z (click for Z→A)' : entrySort === 'desc' ? 'Sorted Z→A (click to reset)' : 'Sort alphabetically' }} + +
-
{{ entry.aspect }}
+
+ {{ entry.aspect }} + + + +
Examples: @@ -365,6 +413,29 @@ export default { const architecturalRoleValue = computed(() => metadataValue.value?.architecturalRole || '') const activeCategoryId = ref('') const applicabilityFilter = ref('all') + const entrySearch = ref('') + const entrySort = ref('') + + const hiddenEntries = computed(() => + props.questionnaireId ? store.getQuestionnaireHiddenEntries(props.questionnaireId) : new Set() + ) + + function hideEntry(entryId) { + if (!props.questionnaireId) return + const updated = new Set([...hiddenEntries.value, entryId]) + store.setQuestionnaireHiddenEntries(props.questionnaireId, updated) + } + + function showAllEntries() { + if (!props.questionnaireId) return + store.setQuestionnaireHiddenEntries(props.questionnaireId, new Set()) + } + + function cycleSort() { + if (entrySort.value === '') entrySort.value = 'asc' + else if (entrySort.value === 'asc') entrySort.value = 'desc' + else entrySort.value = '' + } function toArray(value) { if (!value) return [] @@ -417,16 +488,39 @@ export default { const visibleEntries = computed(() => { const entries = Array.isArray(currentCategory.value.entries) ? currentCategory.value.entries : [] - const filteredByMetadata = entries.filter((entry) => appliesToMatches(entry.appliesTo, metadataValue.value)) - - if (applicabilityFilter.value === 'all') { - return filteredByMetadata + let result = entries.filter((entry) => appliesToMatches(entry.appliesTo, metadataValue.value)) + + if (applicabilityFilter.value !== 'all') { + result = result.filter((entry) => { + const entryApplicability = entry.applicability || 'applicable' + return entryApplicability === applicabilityFilter.value + }) } - - return filteredByMetadata.filter((entry) => { - const entryApplicability = entry.applicability || 'applicable' - return entryApplicability === applicabilityFilter.value - }) + + if (entrySearch.value && entrySearch.value.trim()) { + const q = entrySearch.value.trim().toLowerCase() + result = result.filter((entry) => + (entry.aspect || '').toLowerCase().includes(q) || + (entry.description || '').toLowerCase().includes(q) + ) + } + + if (entrySort.value === 'asc') { + result = [...result].sort((a, b) => (a.aspect || '').localeCompare(b.aspect || '')) + } else if (entrySort.value === 'desc') { + result = [...result].sort((a, b) => (b.aspect || '').localeCompare(a.aspect || '')) + } + + return result.filter((entry) => !hiddenEntries.value.has(entry.id)) + }) + + const currentCategoryHiddenCount = computed(() => { + const entries = Array.isArray(currentCategory.value.entries) ? currentCategory.value.entries : [] + let result = entries.filter((entry) => appliesToMatches(entry.appliesTo, metadataValue.value)) + if (applicabilityFilter.value !== 'all') { + result = result.filter((entry) => (entry.applicability || 'applicable') === applicabilityFilter.value) + } + return result.filter((entry) => hiddenEntries.value.has(entry.id)).length }) const applicabilityFilterOptions = computed(() => { @@ -460,6 +554,8 @@ export default { function selectCategory(id) { activeCategoryId.value = id + entrySearch.value = '' + entrySort.value = '' scrollToTop() } @@ -495,6 +591,8 @@ export default { const idx = visibleCategories.value.findIndex((category) => category.id === activeCategoryId.value) if (idx < visibleCategories.value.length - 1) { activeCategoryId.value = visibleCategories.value[idx + 1].id + entrySearch.value = '' + entrySort.value = '' scrollToTop() } } @@ -503,6 +601,8 @@ export default { const idx = visibleCategories.value.findIndex((category) => category.id === activeCategoryId.value) if (idx > 0) { activeCategoryId.value = visibleCategories.value[idx - 1].id + entrySearch.value = '' + entrySort.value = '' scrollToTop() } } @@ -595,7 +695,13 @@ export default { answerTypeOptions, isReference, parentProject, - toggleReference + toggleReference, + entrySearch, + entrySort, + cycleSort, + hideEntry, + showAllEntries, + currentCategoryHiddenCount } } } @@ -612,6 +718,32 @@ export default { resize: vertical; } +.entry-title-row { + flex-wrap: nowrap; +} + +.entry-hide-btn { + opacity: 0; + transition: opacity 0.12s; + flex-shrink: 0; +} +.entry-title-row:hover .entry-hide-btn { + opacity: 0.5; +} +.entry-hide-btn:hover { + opacity: 1 !important; +} + +.hidden-entries-chip { + cursor: pointer; + opacity: 0.4; + transition: opacity 0.15s; + font-size: 11px !important; +} +.hidden-entries-chip:hover { + opacity: 0.85; +} + .entry-highlighted { animation: entry-flash 2s ease-out; border-radius: 4px; diff --git a/src/stores/workspaceStore.js b/src/stores/workspaceStore.js index 7f1a72f..0d6a355 100644 --- a/src/stores/workspaceStore.js +++ b/src/stores/workspaceStore.js @@ -43,6 +43,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { const autoSaveStarted = ref(false) const pendingNavigation = ref(null) // { questionnaireId, categoryId, entryId } | null const workspaceDirNeeded = ref(false) + const questionnaireHiddenEntries = ref({}) // Record const activeQuestionnaire = computed(() => { return workspace.value.questionnaires.find((item) => item.id === activeQuestionnaireId.value) || null @@ -94,6 +95,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { openQuestionnaireIds.value = [] activeWorkspaceTabId.value = '' openProjectSummaryIds.value = [] + questionnaireHiddenEntries.value = data.questionnaireHiddenEntries || {} hydrateLastSaved(data.timestamp) return true } @@ -104,6 +106,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { openQuestionnaireIds.value = [] activeWorkspaceTabId.value = '' openProjectSummaryIds.value = [] + questionnaireHiddenEntries.value = {} hydrateLastSaved(data.timestamp) return true } @@ -170,7 +173,7 @@ export const useWorkspaceStore = defineStore('workspace', () => { if (autoSaveStarted.value) return autoSaveStarted.value = true watch( - () => [workspace.value, activeQuestionnaireId.value, openQuestionnaireIds.value, activeWorkspaceTabId.value, openProjectSummaryIds.value], + () => [workspace.value, activeQuestionnaireId.value, openQuestionnaireIds.value, activeWorkspaceTabId.value, openProjectSummaryIds.value, questionnaireHiddenEntries.value], () => { clearTimeout(persistDebounceTimer) persistDebounceTimer = setTimeout(() => persist(), 500) @@ -187,7 +190,8 @@ export const useWorkspaceStore = defineStore('workspace', () => { activeQuestionnaireId: activeQuestionnaireId.value, openQuestionnaireIds: openQuestionnaireIds.value, activeWorkspaceTabId: activeWorkspaceTabId.value, - openProjectSummaryIds: openProjectSummaryIds.value + openProjectSummaryIds: openProjectSummaryIds.value, + questionnaireHiddenEntries: questionnaireHiddenEntries.value } if (window.electronAPI) { @@ -246,6 +250,17 @@ export const useWorkspaceStore = defineStore('workspace', () => { pendingNavigation.value = null } + function getQuestionnaireHiddenEntries(questionnaireId) { + return new Set(questionnaireHiddenEntries.value[questionnaireId] || []) + } + + function setQuestionnaireHiddenEntries(questionnaireId, entryIdSet) { + questionnaireHiddenEntries.value = { + ...questionnaireHiddenEntries.value, + [questionnaireId]: [...entryIdSet] + } + } + function openProjectSummary(projectId) { const project = workspace.value.projects.find((item) => item.id === projectId) if (!project) return @@ -853,6 +868,8 @@ export const useWorkspaceStore = defineStore('workspace', () => { setReferenceQuestionnaire, pendingNavigation, navigateToEntry, - clearPendingNavigation + clearPendingNavigation, + getQuestionnaireHiddenEntries, + setQuestionnaireHiddenEntries } })