Compare commits
27 Commits
refactor-i
...
1.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 11f122552d | |||
| c84a3b6705 | |||
| e64902321f | |||
| 7abce8f95c | |||
| 88dce4d100 | |||
|
|
c4eb6f2d1d | ||
| 06fdaff879 | |||
| 8024a3d02a | |||
|
|
223031866b | ||
|
|
cb75b25529 | ||
| 83b470e28a | |||
|
|
acf104eaa7 | ||
|
|
e793d7a9e2 | ||
|
|
3ecae0be0f | ||
|
|
d37e53b1a9 | ||
|
|
2f89c7e13b | ||
|
|
6bf4055c2f | ||
|
|
bf7ee630d0 | ||
| 1739567b18 | |||
|
|
a5a9af5ddc | ||
|
|
4e3e293495 | ||
|
|
65533c15d2 | ||
|
|
2530bc0ec2 | ||
| b1fa6ac458 | |||
| 9ff24f8258 | |||
|
|
9a3409c29f | ||
|
|
a142737771 |
10
BUILDING.md
10
BUILDING.md
@@ -1161,7 +1161,7 @@ export GEM_PATH=$shortened_path
|
|||||||
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
|
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ios/App && xcrun agvtool new-version 47 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.2;/g" App.xcodeproj/project.pbxproj && cd -
|
cd ios/App && xcrun agvtool new-version 48 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.3;/g" App.xcodeproj/project.pbxproj && cd -
|
||||||
# Unfortunately this edits Info.plist directly.
|
# Unfortunately this edits Info.plist directly.
|
||||||
#xcrun agvtool new-marketing-version 0.4.5
|
#xcrun agvtool new-marketing-version 0.4.5
|
||||||
```
|
```
|
||||||
@@ -1304,8 +1304,8 @@ The recommended way to build for Android is using the automated build script:
|
|||||||
# Standard build and open Android Studio
|
# Standard build and open Android Studio
|
||||||
./scripts/build-android.sh
|
./scripts/build-android.sh
|
||||||
|
|
||||||
# Build with specific version numbers
|
# Build with specific version numbers -- doesn't change source files
|
||||||
./scripts/build-android.sh --version 1.0.3 --build-number 35
|
#./scripts/build-android.sh --version 1.1.3 --build-number 48
|
||||||
|
|
||||||
# Build without opening Android Studio (for CI/CD)
|
# Build without opening Android Studio (for CI/CD)
|
||||||
./scripts/build-android.sh --no-studio
|
./scripts/build-android.sh --no-studio
|
||||||
@@ -1319,8 +1319,8 @@ The recommended way to build for Android is using the automated build script:
|
|||||||
##### 1. Bump the version in package.json, then update these versions & run:
|
##### 1. Bump the version in package.json, then update these versions & run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
perl -p -i -e 's/versionCode .*/versionCode 47/g' android/app/build.gradle
|
perl -p -i -e 's/versionCode .*/versionCode 48/g' android/app/build.gradle
|
||||||
perl -p -i -e 's/versionName .*/versionName "1.1.2"/g' android/app/build.gradle
|
perl -p -i -e 's/versionName .*/versionName "1.1.3"/g' android/app/build.gradle
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -6,8 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
## [1.1.2] - 2025.11.06
|
## [1.1.3] - 2025.11.19
|
||||||
|
### Changed
|
||||||
|
- Project selection in dialogs now reaches out to server when filtering
|
||||||
|
- Project selection during onboarding meeting is a search (not an input box)
|
||||||
|
- Improve the switching of agent when agent edits a project
|
||||||
|
### Fixed
|
||||||
|
- Reassignment of "you" as recipient when changing giver project
|
||||||
|
- Bad counts for project-change notification on front page
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.2] - 2025.11.06
|
||||||
### Fixed
|
### Fixed
|
||||||
- Bad page when user follows prompt to backup seed
|
- Bad page when user follows prompt to backup seed
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ android {
|
|||||||
applicationId "app.timesafari.app"
|
applicationId "app.timesafari.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 47
|
versionCode 48
|
||||||
versionName "1.1.2"
|
versionName "1.1.3"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -403,7 +403,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -413,7 +413,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.2;
|
MARKETING_VERSION = 1.1.3;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -430,7 +430,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.2;
|
MARKETING_VERSION = 1.1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.1.3-beta",
|
"version": "1.1.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.1.3-beta",
|
"version": "1.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/electron": "^5.0.1",
|
"@capacitor-community/electron": "^5.0.1",
|
||||||
"@capacitor-community/sqlite": "6.0.2",
|
"@capacitor-community/sqlite": "6.0.2",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@ethersproject/hdnode": "^5.7.0",
|
"@ethersproject/hdnode": "^5.7.0",
|
||||||
"@ethersproject/wallet": "^5.8.0",
|
"@ethersproject/wallet": "^5.8.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||||
@@ -6789,6 +6790,25 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fortawesome/free-brands-svg-icons": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-9byUd9bgNfthsZAjBl6GxOu1VPHgBuRUP9juI7ZoM98h8xNPTCTagfwUFyYscdZq4Hr7gD1azMfM9s5tIWKZZA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-common-types": "7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fortawesome/free-brands-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-l/BQM7fYntsCI//du+6sEnHOP6a74UixFyOYUyz2DLMXKx+6DEhfR3F2NYGE45XH1JJuIamacb4IZs9S0ZOWLA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fortawesome/free-regular-svg-icons": {
|
"node_modules/@fortawesome/free-regular-svg-icons": {
|
||||||
"version": "6.7.2",
|
"version": "6.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "timesafari",
|
"name": "timesafari",
|
||||||
"version": "1.1.3-beta",
|
"version": "1.1.3",
|
||||||
"description": "Time Safari Application",
|
"description": "Time Safari Application",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Time Safari Team"
|
"name": "Time Safari Team"
|
||||||
@@ -156,6 +156,7 @@
|
|||||||
"@ethersproject/hdnode": "^5.7.0",
|
"@ethersproject/hdnode": "^5.7.0",
|
||||||
"@ethersproject/wallet": "^5.8.0",
|
"@ethersproject/wallet": "^5.8.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
"@fortawesome/free-solid-svg-icons": "^6.5.1",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.6",
|
"@fortawesome/vue-fontawesome": "^3.0.6",
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Empty state message -->
|
<!-- Empty state message -->
|
||||||
<li v-if="entities.length === 0" :class="emptyStateClasses">
|
<li v-if="hasNoEntities" :class="emptyStateClasses">
|
||||||
{{ emptyStateMessage }}
|
{{ emptyStateMessage }}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
<li
|
<li
|
||||||
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||||
>
|
>
|
||||||
Everyone Else
|
Everyone
|
||||||
</li>
|
</li>
|
||||||
<PersonCard
|
<PersonCard
|
||||||
v-for="person in alphabeticalContacts"
|
v-for="person in alphabeticalContacts"
|
||||||
@@ -164,6 +164,10 @@ import { Contact } from "../db/tables/contacts";
|
|||||||
import { PlanData } from "../interfaces/records";
|
import { PlanData } from "../interfaces/records";
|
||||||
import { NotificationIface } from "../constants/app";
|
import { NotificationIface } from "../constants/app";
|
||||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import { getHeaders } from "../libs/endorserServer";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { TIMEOUTS } from "@/utils/notify";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants for infinite scroll configuration
|
* Constants for infinite scroll configuration
|
||||||
@@ -191,6 +195,7 @@ const RECENT_CONTACTS_COUNT = 3;
|
|||||||
ProjectCard,
|
ProjectCard,
|
||||||
SpecialEntityCard,
|
SpecialEntityCard,
|
||||||
},
|
},
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
export default class EntityGrid extends Vue {
|
export default class EntityGrid extends Vue {
|
||||||
/** Type of entities to display */
|
/** Type of entities to display */
|
||||||
@@ -202,15 +207,36 @@ export default class EntityGrid extends Vue {
|
|||||||
isSearching = false;
|
isSearching = false;
|
||||||
searchTimeout: NodeJS.Timeout | null = null;
|
searchTimeout: NodeJS.Timeout | null = null;
|
||||||
filteredEntities: Contact[] | PlanData[] = [];
|
filteredEntities: Contact[] | PlanData[] = [];
|
||||||
|
searchBeforeId: string | undefined = undefined;
|
||||||
|
isLoadingSearchMore = false;
|
||||||
|
|
||||||
|
// API server for project searches
|
||||||
|
apiServer = "";
|
||||||
|
|
||||||
|
// Internal project state (when entities prop not provided for projects)
|
||||||
|
allProjects: PlanData[] = [];
|
||||||
|
loadBeforeId: string | undefined = undefined;
|
||||||
|
isLoadingProjects = false;
|
||||||
|
|
||||||
// Infinite scroll state
|
// Infinite scroll state
|
||||||
displayedCount = INITIAL_BATCH_SIZE;
|
displayedCount = INITIAL_BATCH_SIZE;
|
||||||
infiniteScrollReset?: () => void;
|
infiniteScrollReset?: () => void;
|
||||||
scrollContainer?: HTMLElement;
|
scrollContainer?: HTMLElement;
|
||||||
|
|
||||||
/** Array of entities to display */
|
/**
|
||||||
@Prop({ required: true })
|
* Array of entities to display
|
||||||
entities!: Contact[] | PlanData[];
|
*
|
||||||
|
* For contacts (entityType === 'people'): REQUIRED - Must be a COMPLETE list from local database.
|
||||||
|
* Use $contactsByDateAdded() to ensure all contacts are included.
|
||||||
|
* Client-side filtering assumes the complete list is available.
|
||||||
|
* IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
|
||||||
|
* (newest first) for the "Recently Added" section to display correctly.
|
||||||
|
*
|
||||||
|
* For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads
|
||||||
|
* projects internally from the API server. If provided, uses the provided list.
|
||||||
|
*/
|
||||||
|
@Prop({ required: false })
|
||||||
|
entities?: Contact[] | PlanData[];
|
||||||
|
|
||||||
/** Active user's DID */
|
/** Active user's DID */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
@@ -282,6 +308,33 @@ export default class EntityGrid extends Vue {
|
|||||||
return "text-xs text-slate-500 italic col-span-full";
|
return "text-xs text-slate-500 italic col-span-full";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are no entities to display
|
||||||
|
*/
|
||||||
|
get hasNoEntities(): boolean {
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// For projects: check internal state if no entities prop, otherwise check prop
|
||||||
|
const projectsToCheck = this.entities || this.allProjects;
|
||||||
|
return projectsToCheck.length === 0;
|
||||||
|
} else {
|
||||||
|
// For people: entities prop is required
|
||||||
|
return !this.entities || this.entities.length === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the entities array to use (prop or internal state)
|
||||||
|
*/
|
||||||
|
get entitiesToUse(): Contact[] | PlanData[] {
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// For projects: use prop if provided, otherwise use internal state
|
||||||
|
return this.entities || this.allProjects;
|
||||||
|
} else {
|
||||||
|
// For people: entities prop is required
|
||||||
|
return this.entities || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
|
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
|
||||||
* When searching, returns filtered results with infinite scroll applied
|
* When searching, returns filtered results with infinite scroll applied
|
||||||
@@ -294,12 +347,12 @@ export default class EntityGrid extends Vue {
|
|||||||
|
|
||||||
// If custom function provided, use it (disables infinite scroll)
|
// If custom function provided, use it (disables infinite scroll)
|
||||||
if (this.displayEntitiesFunction) {
|
if (this.displayEntitiesFunction) {
|
||||||
return this.displayEntitiesFunction(this.entities, this.entityType);
|
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: projects use infinite scroll
|
// Default: projects use infinite scroll
|
||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
return (this.entities as PlanData[]).slice(0, this.displayedCount);
|
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
|
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
|
||||||
@@ -307,14 +360,21 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the 3 most recently added contacts (when showing contacts and not searching)
|
* Get the most recently added contacts (when showing contacts and not searching)
|
||||||
|
*
|
||||||
|
* NOTE: This assumes entities are already sorted by date added (newest first).
|
||||||
|
* See the entities prop documentation for details on using $contactsByDateAdded().
|
||||||
*/
|
*/
|
||||||
get recentContacts(): Contact[] {
|
get recentContacts(): Contact[] {
|
||||||
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
if (
|
||||||
|
this.entityType !== "people" ||
|
||||||
|
this.searchTerm.trim() ||
|
||||||
|
!this.entities
|
||||||
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Entities are already sorted by date added (newest first)
|
// Entities are already sorted by date added (newest first)
|
||||||
return (this.entities as Contact[]).slice(0, 3);
|
return (this.entities as Contact[]).slice(0, RECENT_CONTACTS_COUNT);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -322,19 +382,23 @@ export default class EntityGrid extends Vue {
|
|||||||
* Uses infinite scroll to control how many are displayed
|
* Uses infinite scroll to control how many are displayed
|
||||||
*/
|
*/
|
||||||
get alphabeticalContacts(): Contact[] {
|
get alphabeticalContacts(): Contact[] {
|
||||||
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
if (
|
||||||
|
this.entityType !== "people" ||
|
||||||
|
this.searchTerm.trim() ||
|
||||||
|
!this.entities
|
||||||
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Skip the first 3 (recent contacts) and sort the rest alphabetically
|
// Skip the first few (recent contacts) and sort the rest alphabetically
|
||||||
// Create a copy to avoid mutating the original array
|
// Create a copy to avoid mutating the original array
|
||||||
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
|
const remaining = this.entities as Contact[];
|
||||||
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
|
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
|
||||||
// Sort alphabetically by name, falling back to DID if name is missing
|
// Sort alphabetically by name, falling back to DID if name is missing
|
||||||
const nameA = (a.name || a.did).toLowerCase();
|
const nameA = (a.name || a.did).toLowerCase();
|
||||||
const nameB = (b.name || b.did).toLowerCase();
|
const nameB = (b.name || b.did).toLowerCase();
|
||||||
return nameA.localeCompare(nameB);
|
return nameA.localeCompare(nameB);
|
||||||
});
|
});
|
||||||
// Apply infinite scroll: show based on displayedCount (minus the 3 recent)
|
// Apply infinite scroll: show based on displayedCount (minus the recent contacts)
|
||||||
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
|
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
|
||||||
return sorted.slice(0, toShow);
|
return sorted.slice(0, toShow);
|
||||||
}
|
}
|
||||||
@@ -443,24 +507,209 @@ export default class EntityGrid extends Vue {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform the actual search
|
* Perform the actual search
|
||||||
|
* Routes to server-side search for projects or client-side filtering for contacts
|
||||||
*/
|
*/
|
||||||
async performSearch(): Promise<void> {
|
async performSearch(): Promise<void> {
|
||||||
if (!this.searchTerm.trim()) {
|
if (!this.searchTerm.trim()) {
|
||||||
this.filteredEntities = [];
|
this.filteredEntities = [];
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
|
this.searchBeforeId = undefined; // Reset pagination for new search
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate async search (in case we need to add API calls later)
|
if (this.entityType === "projects") {
|
||||||
|
// Server-side search for projects (initial load, no beforeId)
|
||||||
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
|
await this.fetchProjects(undefined, searchLower);
|
||||||
|
} else {
|
||||||
|
// Client-side filtering for contacts (complete list)
|
||||||
|
await this.performContactSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset displayed count when search completes
|
||||||
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
} finally {
|
||||||
|
this.isSearching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch projects from API server
|
||||||
|
* Unified method for both loading all projects and searching projects.
|
||||||
|
* If claimContents is provided, performs search and updates filteredEntities.
|
||||||
|
* If claimContents is not provided, loads all projects and updates allProjects.
|
||||||
|
*
|
||||||
|
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
||||||
|
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
|
||||||
|
*/
|
||||||
|
async fetchProjects(
|
||||||
|
beforeId?: string,
|
||||||
|
claimContents?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!this.apiServer) {
|
||||||
|
if (claimContents) {
|
||||||
|
this.filteredEntities = [];
|
||||||
|
} else {
|
||||||
|
this.allProjects = [];
|
||||||
|
}
|
||||||
|
if (this.notify) {
|
||||||
|
this.notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "API server not configured",
|
||||||
|
},
|
||||||
|
TIMEOUTS.SHORT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSearch = !!claimContents;
|
||||||
|
let url = `${this.apiServer}/api/v2/report/plans`;
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
const params: string[] = [];
|
||||||
|
if (claimContents) {
|
||||||
|
params.push(
|
||||||
|
`claimContents=${encodeURIComponent(claimContents.toLowerCase().trim())}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (beforeId) {
|
||||||
|
params.push(`beforeId=${encodeURIComponent(beforeId)}`);
|
||||||
|
}
|
||||||
|
if (params.length > 0) {
|
||||||
|
url += `?${params.join("&")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: await getHeaders(this.activeDid),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(
|
||||||
|
isSearch ? "Failed to search projects" : "Failed to load projects",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await response.json();
|
||||||
|
if (results.data) {
|
||||||
|
const newProjects = results.data.map(
|
||||||
|
(plan: PlanData & { rowId?: string }) => ({
|
||||||
|
...plan,
|
||||||
|
rowId: plan.rowId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSearch) {
|
||||||
|
// Search mode: update filteredEntities
|
||||||
|
if (beforeId) {
|
||||||
|
// Pagination: append new projects to existing search results
|
||||||
|
this.filteredEntities.push(...newProjects);
|
||||||
|
} else {
|
||||||
|
// Initial search: replace array
|
||||||
|
this.filteredEntities = newProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update searchBeforeId for next pagination
|
||||||
|
if (newProjects.length > 0) {
|
||||||
|
const lastProject = newProjects[newProjects.length - 1];
|
||||||
|
this.searchBeforeId = lastProject.rowId || undefined;
|
||||||
|
} else {
|
||||||
|
this.searchBeforeId = undefined; // No more results
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Load mode: update allProjects
|
||||||
|
if (beforeId) {
|
||||||
|
// Pagination: append new projects
|
||||||
|
this.allProjects.push(...newProjects);
|
||||||
|
} else {
|
||||||
|
// Initial load: replace array
|
||||||
|
this.allProjects = newProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update loadBeforeId for next pagination
|
||||||
|
if (newProjects.length > 0) {
|
||||||
|
const lastProject = newProjects[newProjects.length - 1];
|
||||||
|
this.loadBeforeId = lastProject.rowId || undefined;
|
||||||
|
} else {
|
||||||
|
this.loadBeforeId = undefined; // No more results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No data in response
|
||||||
|
if (isSearch) {
|
||||||
|
if (!beforeId) {
|
||||||
|
// Only clear on initial search, not pagination
|
||||||
|
this.filteredEntities = [];
|
||||||
|
}
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
} else {
|
||||||
|
if (!beforeId) {
|
||||||
|
// Only clear on initial load, not pagination
|
||||||
|
this.allProjects = [];
|
||||||
|
}
|
||||||
|
this.loadBeforeId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Error ${isSearch ? "searching" : "loading"} projects:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
if (isSearch) {
|
||||||
|
if (!beforeId) {
|
||||||
|
// Only clear on initial search error, not pagination error
|
||||||
|
this.filteredEntities = [];
|
||||||
|
}
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
} else {
|
||||||
|
if (!beforeId) {
|
||||||
|
// Only clear on initial load error, not pagination error
|
||||||
|
this.allProjects = [];
|
||||||
|
}
|
||||||
|
this.loadBeforeId = undefined;
|
||||||
|
}
|
||||||
|
if (this.notify) {
|
||||||
|
this.notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: isSearch
|
||||||
|
? "Failed to search projects. Please try again."
|
||||||
|
: "Failed to load projects. Please try again.",
|
||||||
|
},
|
||||||
|
TIMEOUTS.STANDARD,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side contact search
|
||||||
|
* Assumes entities prop contains complete contact list from local database
|
||||||
|
*/
|
||||||
|
async performContactSearch(): Promise<void> {
|
||||||
|
if (!this.entities) {
|
||||||
|
this.filteredEntities = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate async (for consistency with project search)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
|
|
||||||
if (this.entityType === "people") {
|
|
||||||
this.filteredEntities = (this.entities as Contact[])
|
this.filteredEntities = (this.entities as Contact[])
|
||||||
.filter((contact: Contact) => {
|
.filter((contact: Contact) => {
|
||||||
const name = contact.name?.toLowerCase() || "";
|
const name = contact.name?.toLowerCase() || "";
|
||||||
@@ -473,25 +722,9 @@ export default class EntityGrid extends Vue {
|
|||||||
const nameB = (b.name || b.did).toLowerCase();
|
const nameB = (b.name || b.did).toLowerCase();
|
||||||
return nameA.localeCompare(nameB);
|
return nameA.localeCompare(nameB);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
this.filteredEntities = (this.entities as PlanData[])
|
|
||||||
.filter((project: PlanData) => {
|
|
||||||
const name = project.name?.toLowerCase() || "";
|
|
||||||
const handleId = project.handleId.toLowerCase();
|
|
||||||
return name.includes(searchLower) || handleId.includes(searchLower);
|
|
||||||
})
|
|
||||||
.sort((a: PlanData, b: PlanData) => {
|
|
||||||
// Sort alphabetically by name
|
|
||||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset displayed count when search completes
|
// Contacts don't need pagination (complete list)
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.searchBeforeId = undefined;
|
||||||
this.infiniteScrollReset?.();
|
|
||||||
} finally {
|
|
||||||
this.isSearching = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -502,6 +735,7 @@ export default class EntityGrid extends Vue {
|
|||||||
this.filteredEntities = [];
|
this.filteredEntities = [];
|
||||||
this.isSearching = false;
|
this.isSearching = false;
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
|
|
||||||
// Clear any pending timeout
|
// Clear any pending timeout
|
||||||
@@ -521,35 +755,161 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.searchTerm.trim()) {
|
if (this.searchTerm.trim()) {
|
||||||
// Search mode: check filtered entities
|
// Search mode: check if more results available
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// Projects: can load more if:
|
||||||
|
// 1. We have more already-loaded results to show, OR
|
||||||
|
// 2. We've shown all loaded results AND there's a searchBeforeId to load more
|
||||||
|
const hasMoreLoaded =
|
||||||
|
this.displayedCount < this.filteredEntities.length;
|
||||||
|
const canLoadMoreFromServer =
|
||||||
|
this.displayedCount >= this.filteredEntities.length &&
|
||||||
|
!!this.searchBeforeId &&
|
||||||
|
!this.isLoadingSearchMore;
|
||||||
|
return hasMoreLoaded || canLoadMoreFromServer;
|
||||||
|
} else {
|
||||||
|
// Contacts: client-side filtering returns all results at once
|
||||||
return this.displayedCount < this.filteredEntities.length;
|
return this.displayedCount < this.filteredEntities.length;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-search mode
|
||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
// Projects: check if more available
|
// Projects: check internal state or prop
|
||||||
return this.displayedCount < this.entities.length;
|
const projectsToCheck = this.entities || this.allProjects;
|
||||||
|
const beforeId = this.entities ? undefined : this.loadBeforeId;
|
||||||
|
|
||||||
|
// Can load more if:
|
||||||
|
// 1. We have more already-loaded results to show, OR
|
||||||
|
// 2. We've shown all loaded results AND there's a beforeId to load more (and not using entities prop)
|
||||||
|
const hasMoreLoaded = this.displayedCount < projectsToCheck.length;
|
||||||
|
const canLoadMoreFromServer =
|
||||||
|
!this.entities &&
|
||||||
|
this.displayedCount >= projectsToCheck.length &&
|
||||||
|
!!beforeId &&
|
||||||
|
!this.isLoadingProjects;
|
||||||
|
|
||||||
|
return hasMoreLoaded || canLoadMoreFromServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// People: check if more alphabetical contacts available
|
// People: check if more alphabetical contacts available
|
||||||
// Total available = 3 recent + all alphabetical
|
// Total available = recent + all alphabetical
|
||||||
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
|
if (!this.entities) {
|
||||||
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
|
return false;
|
||||||
|
}
|
||||||
|
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
|
||||||
return this.displayedCount < totalAvailable;
|
return this.displayedCount < totalAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize infinite scroll on mount
|
* Initialize infinite scroll on mount
|
||||||
*/
|
*/
|
||||||
mounted(): void {
|
async mounted(): Promise<void> {
|
||||||
|
// Load apiServer for project searches/loads
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
this.apiServer = settings.apiServer || "";
|
||||||
|
|
||||||
|
// Load projects on mount if entities prop not provided
|
||||||
|
if (!this.entities && this.apiServer) {
|
||||||
|
this.isLoadingProjects = true;
|
||||||
|
try {
|
||||||
|
await this.fetchProjects();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading projects on mount:", error);
|
||||||
|
} finally {
|
||||||
|
this.isLoadingProjects = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate entities prop for people
|
||||||
|
if (this.entityType === "people" && !this.entities) {
|
||||||
|
logger.error(
|
||||||
|
"EntityGrid: entities prop is required when entityType is 'people'",
|
||||||
|
);
|
||||||
|
if (this.notify) {
|
||||||
|
this.notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Contacts data is required but not provided.",
|
||||||
|
},
|
||||||
|
TIMEOUTS.SHORT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const container = this.$refs.scrollContainer as HTMLElement;
|
const container = this.$refs.scrollContainer as HTMLElement;
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
const { reset } = useInfiniteScroll(
|
const { reset } = useInfiniteScroll(
|
||||||
container,
|
container,
|
||||||
() => {
|
async () => {
|
||||||
// Load more: increment displayedCount
|
// Search mode: handle search pagination
|
||||||
|
if (this.searchTerm.trim()) {
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// Projects: load more search results if available
|
||||||
|
if (
|
||||||
|
this.displayedCount >= this.filteredEntities.length &&
|
||||||
|
this.searchBeforeId &&
|
||||||
|
!this.isLoadingSearchMore
|
||||||
|
) {
|
||||||
|
this.isLoadingSearchMore = true;
|
||||||
|
try {
|
||||||
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
|
await this.fetchProjects(this.searchBeforeId, searchLower);
|
||||||
|
// After loading more, reset scroll state to allow further loading
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading more search results:", error);
|
||||||
|
// Error already handled in fetchProjects
|
||||||
|
} finally {
|
||||||
|
this.isLoadingSearchMore = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show more from already-loaded search results
|
||||||
this.displayedCount += INCREMENT_SIZE;
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Contacts: show more from already-filtered results
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-search mode
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
const projectsToCheck = this.entities || this.allProjects;
|
||||||
|
const beforeId = this.entities ? undefined : this.loadBeforeId;
|
||||||
|
|
||||||
|
// If using internal state and need to load more from server
|
||||||
|
if (
|
||||||
|
!this.entities &&
|
||||||
|
this.displayedCount >= projectsToCheck.length &&
|
||||||
|
beforeId &&
|
||||||
|
!this.isLoadingProjects
|
||||||
|
) {
|
||||||
|
this.isLoadingProjects = true;
|
||||||
|
try {
|
||||||
|
await this.fetchProjects(beforeId);
|
||||||
|
// After loading more, reset scroll state to allow further loading
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading more projects:", error);
|
||||||
|
// Error already handled in fetchProjects
|
||||||
|
} finally {
|
||||||
|
this.isLoadingProjects = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal case: increment displayedCount to show more from memory
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// People: increment displayedCount to show more from memory
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
distance: 50, // pixels from bottom
|
distance: 50, // pixels from bottom
|
||||||
@@ -575,21 +935,35 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch for changes in search term to reset displayed count
|
* Watch for changes in search term to reset displayed count and pagination
|
||||||
*/
|
*/
|
||||||
@Watch("searchTerm")
|
@Watch("searchTerm")
|
||||||
onSearchTermChange(): void {
|
onSearchTermChange(): void {
|
||||||
|
// Reset displayed count and pagination when search term changes
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch for changes in entities prop to reset displayed count
|
* Watch for changes in entities prop to clear search and reset displayed count
|
||||||
*/
|
*/
|
||||||
@Watch("entities")
|
@Watch("entities")
|
||||||
onEntitiesChange(): void {
|
onEntitiesChange(): void {
|
||||||
|
// Clear search when entities change (fresh dialog open)
|
||||||
|
if (this.searchTerm) {
|
||||||
|
this.searchTerm = "";
|
||||||
|
this.filteredEntities = [];
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
}
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
|
|
||||||
|
// For projects: if entities prop is provided, clear internal state
|
||||||
|
if (this.entityType === "projects" && this.entities) {
|
||||||
|
this.allProjects = [];
|
||||||
|
this.loadBeforeId = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ properties * * @author Matthew Raymer */
|
|||||||
|
|
||||||
<EntityGrid
|
<EntityGrid
|
||||||
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
:entity-type="shouldShowProjects ? 'projects' : 'people'"
|
||||||
:entities="shouldShowProjects ? projects : allContacts"
|
:entities="shouldShowProjects ? projects || undefined : allContacts"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
@@ -94,9 +94,9 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
@Prop({ default: false })
|
@Prop({ default: false })
|
||||||
isFromProjectView!: boolean;
|
isFromProjectView!: boolean;
|
||||||
|
|
||||||
/** Array of available projects */
|
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
|
||||||
@Prop({ required: true })
|
@Prop({ required: false })
|
||||||
projects!: PlanData[];
|
projects?: PlanData[];
|
||||||
|
|
||||||
/** Array of available contacts */
|
/** Array of available contacts */
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
giverEntityType === 'project' || recipientEntityType === 'project'
|
giverEntityType === 'project' || recipientEntityType === 'project'
|
||||||
"
|
"
|
||||||
:is-from-project-view="isFromProjectView"
|
:is-from-project-view="isFromProjectView"
|
||||||
:projects="projects"
|
|
||||||
:all-contacts="allContacts"
|
:all-contacts="allContacts"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
@@ -68,7 +67,6 @@ import {
|
|||||||
createAndSubmitGive,
|
createAndSubmitGive,
|
||||||
didInfo,
|
didInfo,
|
||||||
serverMessageForUser,
|
serverMessageForUser,
|
||||||
getHeaders,
|
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import * as libsUtil from "../libs/util";
|
import * as libsUtil from "../libs/util";
|
||||||
import { Contact } from "../db/tables/contacts";
|
import { Contact } from "../db/tables/contacts";
|
||||||
@@ -134,7 +132,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
|
||||||
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
||||||
offerId = "";
|
offerId = "";
|
||||||
projects: PlanData[] = [];
|
|
||||||
prompt = "";
|
prompt = "";
|
||||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||||
stepType = "giver";
|
stepType = "giver";
|
||||||
@@ -234,16 +231,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.allContacts = await this.$contactsByDateAdded();
|
this.allContacts = await this.$contactsByDateAdded();
|
||||||
|
|
||||||
this.allMyDids = await retrieveAccountDids();
|
this.allMyDids = await retrieveAccountDids();
|
||||||
|
|
||||||
if (
|
|
||||||
this.giverEntityType === "project" ||
|
|
||||||
this.recipientEntityType === "project"
|
|
||||||
) {
|
|
||||||
await this.loadProjects();
|
|
||||||
} else {
|
|
||||||
// Clear projects array when not needed
|
|
||||||
this.projects = [];
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
logger.error("Error retrieving settings from database:", err);
|
logger.error("Error retrieving settings from database:", err);
|
||||||
this.safeNotify.error(
|
this.safeNotify.error(
|
||||||
@@ -489,27 +476,6 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.firstStep = false;
|
this.firstStep = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadProjects() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
|
|
||||||
method: "GET",
|
|
||||||
headers: await getHeaders(this.activeDid),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error("Failed to load projects");
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await response.json();
|
|
||||||
if (results.data) {
|
|
||||||
this.projects = results.data;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error loading projects:", error);
|
|
||||||
this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectProject(project: PlanData) {
|
selectProject(project: PlanData) {
|
||||||
this.giver = {
|
this.giver = {
|
||||||
did: project.handleId,
|
did: project.handleId,
|
||||||
@@ -517,10 +483,13 @@ export default class GiftedDialog extends Vue {
|
|||||||
image: project.image,
|
image: project.image,
|
||||||
handleId: project.handleId,
|
handleId: project.handleId,
|
||||||
};
|
};
|
||||||
|
// Only set receiver to "You" if no receiver has been selected yet
|
||||||
|
if (!this.receiver || !this.receiver.did) {
|
||||||
this.receiver = {
|
this.receiver = {
|
||||||
did: this.activeDid,
|
did: this.activeDid,
|
||||||
name: "You",
|
name: "You",
|
||||||
};
|
};
|
||||||
|
}
|
||||||
this.firstStep = false;
|
this.firstStep = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
130
src/components/MeetingProjectDialog.vue
Normal file
130
src/components/MeetingProjectDialog.vue
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<!-- Header -->
|
||||||
|
<h2 class="text-lg font-semibold leading-[1.25] mb-4">Select Project</h2>
|
||||||
|
|
||||||
|
<!-- EntityGrid for projects -->
|
||||||
|
<EntityGrid
|
||||||
|
:entity-type="'projects'"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:conflict-checker="() => false"
|
||||||
|
:show-you-entity="false"
|
||||||
|
:show-unnamed-entity="false"
|
||||||
|
:notify="notify"
|
||||||
|
:conflict-context="'project'"
|
||||||
|
@entity-selected="handleEntitySelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cancel Button -->
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||||
|
import EntityGrid from "./EntityGrid.vue";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import { PlanData } from "../interfaces/records";
|
||||||
|
import { NotificationIface } from "../constants/app";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - EntityGrid integration for project selection
|
||||||
|
* - No special entities (You, Unnamed)
|
||||||
|
* - Immediate assignment on project selection
|
||||||
|
* - Cancel button to close without selection
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
EntityGrid,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class MeetingProjectDialog extends Vue {
|
||||||
|
/** Whether the dialog is visible */
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
/** Active user's DID */
|
||||||
|
@Prop({ required: true })
|
||||||
|
activeDid!: string;
|
||||||
|
|
||||||
|
/** All user's DIDs */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allMyDids!: string[];
|
||||||
|
|
||||||
|
/** All contacts */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allContacts!: Contact[];
|
||||||
|
|
||||||
|
/** Notification function from parent component */
|
||||||
|
@Prop()
|
||||||
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle entity selection from EntityGrid
|
||||||
|
* Immediately assigns the selected project and closes the dialog
|
||||||
|
*/
|
||||||
|
handleEntitySelected(event: {
|
||||||
|
type: "person" | "project";
|
||||||
|
data: Contact | PlanData;
|
||||||
|
}) {
|
||||||
|
const project = event.data as PlanData;
|
||||||
|
this.emitAssign(project);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancel button click
|
||||||
|
*/
|
||||||
|
handleCancel(): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the dialog
|
||||||
|
*/
|
||||||
|
open(): void {
|
||||||
|
this.visible = true;
|
||||||
|
this.emitOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the dialog
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
this.visible = false;
|
||||||
|
this.emitClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit methods using @Emit decorator
|
||||||
|
|
||||||
|
@Emit("assign")
|
||||||
|
emitAssign(project: PlanData): PlanData {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("open")
|
||||||
|
emitOpen(): void {
|
||||||
|
// Emit when dialog opens
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("close")
|
||||||
|
emitClose(): void {
|
||||||
|
// Emit when dialog closes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -8,7 +8,7 @@ issuer information. * * @author Matthew Raymer */
|
|||||||
>
|
>
|
||||||
<ProjectIcon
|
<ProjectIcon
|
||||||
:entity-id="project.handleId"
|
:entity-id="project.handleId"
|
||||||
:icon-size="48"
|
:icon-size="30"
|
||||||
:image-url="project.image"
|
:image-url="project.image"
|
||||||
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
117
src/components/ProjectRepresentativeDialog.vue
Normal file
117
src/components/ProjectRepresentativeDialog.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<!-- Header -->
|
||||||
|
<h2 class="text-lg font-semibold leading-[1.25] mb-4">
|
||||||
|
Select Representative
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- EntityGrid for contacts -->
|
||||||
|
<EntityGrid
|
||||||
|
:entity-type="'people'"
|
||||||
|
:entities="allContacts"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:conflict-checker="() => false"
|
||||||
|
:show-you-entity="false"
|
||||||
|
:show-unnamed-entity="false"
|
||||||
|
:notify="notify"
|
||||||
|
:conflict-context="'representative'"
|
||||||
|
@entity-selected="handleEntitySelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cancel Button -->
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||||
|
import EntityGrid from "./EntityGrid.vue";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import { NotificationIface } from "../constants/app";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProjectRepresentativeDialog - Dialog for selecting an authorized representative
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - EntityGrid integration for contact selection
|
||||||
|
* - No special entities (You, Unnamed)
|
||||||
|
* - Immediate assignment on contact selection
|
||||||
|
* - Cancel button to close without selection
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
EntityGrid,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class ProjectRepresentativeDialog extends Vue {
|
||||||
|
/** Whether the dialog is visible */
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
/** Array of available contacts */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allContacts!: Contact[];
|
||||||
|
|
||||||
|
/** Active user's DID */
|
||||||
|
@Prop({ required: true })
|
||||||
|
activeDid!: string;
|
||||||
|
|
||||||
|
/** All user's DIDs */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allMyDids!: string[];
|
||||||
|
|
||||||
|
/** Notification function from parent component */
|
||||||
|
@Prop()
|
||||||
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle entity selection from EntityGrid
|
||||||
|
* Immediately assigns the selected contact and closes the dialog
|
||||||
|
*/
|
||||||
|
handleEntitySelected(event: { type: "person" | "project"; data: Contact }) {
|
||||||
|
const contact = event.data as Contact;
|
||||||
|
this.emitAssign(contact);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancel button click
|
||||||
|
*/
|
||||||
|
handleCancel(): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the dialog
|
||||||
|
*/
|
||||||
|
open(): void {
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the dialog
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit methods using @Emit decorator
|
||||||
|
|
||||||
|
@Emit("assign")
|
||||||
|
emitAssign(contact: Contact): Contact {
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
29
src/constants/contacts.ts
Normal file
29
src/constants/contacts.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Constants for contact-related functionality
|
||||||
|
* Created: 2025-11-16
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact method types with user-friendly labels
|
||||||
|
* Used in: ContactEditView.vue, DIDView.vue
|
||||||
|
*/
|
||||||
|
export const CONTACT_METHOD_TYPES = [
|
||||||
|
{ value: "CELL", label: "Mobile" },
|
||||||
|
{ value: "EMAIL", label: "Email" },
|
||||||
|
{ value: "WHATSAPP", label: "WhatsApp" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for contact method type values
|
||||||
|
*/
|
||||||
|
export type ContactMethodType = (typeof CONTACT_METHOD_TYPES)[number]["value"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get label for a contact method type
|
||||||
|
* @param type - The contact method type value (e.g., "CELL", "EMAIL")
|
||||||
|
* @returns The user-friendly label or the original type if not found
|
||||||
|
*/
|
||||||
|
export function getContactMethodLabel(type: string): string {
|
||||||
|
const methodType = CONTACT_METHOD_TYPES.find((m) => m.value === type);
|
||||||
|
return methodType ? methodType.label : type;
|
||||||
|
}
|
||||||
@@ -57,7 +57,12 @@ export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
|
|||||||
planName: string;
|
planName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// a summary record; the VC is not currently part of this record
|
/**
|
||||||
|
* A summary record
|
||||||
|
* The VC is not currently part of this record.
|
||||||
|
*
|
||||||
|
* If you change this, you may want to update NewActivityView.vue to handle differences correctly.
|
||||||
|
*/
|
||||||
export interface PlanSummaryRecord {
|
export interface PlanSummaryRecord {
|
||||||
agentDid?: string;
|
agentDid?: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -76,7 +81,9 @@ export interface PlanSummaryRecord {
|
|||||||
|
|
||||||
export interface PlanSummaryAndPreviousClaim {
|
export interface PlanSummaryAndPreviousClaim {
|
||||||
plan: PlanSummaryRecord;
|
plan: PlanSummaryRecord;
|
||||||
wrappedClaimBefore: GenericCredWrapper<PlanActionClaim>;
|
// This can be undefined, eg. if a project is starred after the stored last-seen-change-jwt ID.
|
||||||
|
// The endorser-ch test code shows some cases.
|
||||||
|
wrappedClaimBefore?: GenericCredWrapper<PlanActionClaim>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -85,22 +85,12 @@
|
|||||||
class="absolute bg-white border border-gray-300 rounded-md mt-1"
|
class="absolute bg-white border border-gray-300 rounded-md mt-1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
v-for="methodType in contactMethodTypes"
|
||||||
|
:key="methodType.value"
|
||||||
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
|
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
|
||||||
@click="setMethodType(index, 'CELL')"
|
@click="setMethodType(index, methodType.value)"
|
||||||
>
|
>
|
||||||
CELL
|
{{ methodType.label }}
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
|
|
||||||
@click="setMethodType(index, 'EMAIL')"
|
|
||||||
>
|
|
||||||
EMAIL
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
|
|
||||||
@click="setMethodType(index, 'WHATSAPP')"
|
|
||||||
>
|
|
||||||
WHATSAPP
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,6 +147,7 @@ import {
|
|||||||
} from "../constants/notifications";
|
} from "../constants/notifications";
|
||||||
import { Contact, ContactMethod } from "../db/tables/contacts";
|
import { Contact, ContactMethod } from "../db/tables/contacts";
|
||||||
import { AppString } from "../constants/app";
|
import { AppString } from "../constants/app";
|
||||||
|
import { CONTACT_METHOD_TYPES } from "../constants/contacts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contact Edit View Component
|
* Contact Edit View Component
|
||||||
@@ -224,6 +215,8 @@ export default class ContactEditView extends Vue {
|
|||||||
|
|
||||||
/** App string constants */
|
/** App string constants */
|
||||||
AppString = AppString;
|
AppString = AppString;
|
||||||
|
/** Contact method types for dropdown */
|
||||||
|
contactMethodTypes = CONTACT_METHOD_TYPES;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component lifecycle hook that initializes the contact edit form
|
* Component lifecycle hook that initializes the contact edit form
|
||||||
|
|||||||
@@ -20,12 +20,12 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Help button -->
|
<!-- Help button -->
|
||||||
<button
|
<router-link
|
||||||
|
:to="{ name: 'help' }"
|
||||||
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
||||||
@click="goToHelp()"
|
|
||||||
>
|
>
|
||||||
<font-awesome icon="question" class="block text-center w-[1em]" />
|
<font-awesome icon="question" class="block text-center w-[1em]" />
|
||||||
</button>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Identity Details -->
|
<!-- Identity Details -->
|
||||||
@@ -42,6 +42,39 @@
|
|||||||
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
|
||||||
</router-link>
|
</router-link>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div v-if="contactFromDid.notes" class="mt-3">
|
||||||
|
<p class="text-sm text-slate-700 whitespace-pre-wrap">
|
||||||
|
{{ contactFromDid.notes }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Methods -->
|
||||||
|
<div v-if="contactFromDid.contactMethods?.length" class="mt-3">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(method, index) in contactFromDid.contactMethods"
|
||||||
|
:key="index"
|
||||||
|
class="inline-flex items-center gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-slate-600"
|
||||||
|
>{{ getContactMethodLabel(method.type) }}:</span
|
||||||
|
>
|
||||||
|
<span class="text-slate-700">{{ method.label }}</span>
|
||||||
|
<span class="text-slate-600">{{ method.value }}</span>
|
||||||
|
<a
|
||||||
|
v-if="method.type === 'CELL'"
|
||||||
|
:href="`sms:${method.value}`"
|
||||||
|
class="ml-2 text-blue-500 hover:text-blue-700"
|
||||||
|
title="Send text message"
|
||||||
|
>
|
||||||
|
<font-awesome icon="message" class="text-base" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="ml-2 mr-2 mt-4" @click="toggleDidDetails">
|
<button class="ml-2 mr-2 mt-4" @click="toggleDidDetails">
|
||||||
Details
|
Details
|
||||||
<font-awesome
|
<font-awesome
|
||||||
@@ -302,6 +335,7 @@ import {
|
|||||||
NOTIFY_CONTACT_INVALID_DID,
|
NOTIFY_CONTACT_INVALID_DID,
|
||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
|
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
|
||||||
|
import { getContactMethodLabel } from "@/constants/contacts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DIDView Component
|
* DIDView Component
|
||||||
@@ -352,6 +386,7 @@ export default class DIDView extends Vue {
|
|||||||
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
||||||
didInfoForContact = didInfoForContact;
|
didInfoForContact = didInfoForContact;
|
||||||
displayAmount = displayAmount;
|
displayAmount = displayAmount;
|
||||||
|
getContactMethodLabel = getContactMethodLabel;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes notification helpers
|
* Initializes notification helpers
|
||||||
|
|||||||
@@ -898,7 +898,13 @@ export default class HomeView extends Vue {
|
|||||||
this.starredPlanHandleIds,
|
this.starredPlanHandleIds,
|
||||||
this.lastAckedStarredPlanChangesJwtId,
|
this.lastAckedStarredPlanChangesJwtId,
|
||||||
);
|
);
|
||||||
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
|
// filter out any data elements where there is no wrappedClaimBefore
|
||||||
|
const filteredNewStarredProjectChanges =
|
||||||
|
starredProjectChanges.data.filter(
|
||||||
|
(change) => change.wrappedClaimBefore !== undefined,
|
||||||
|
);
|
||||||
|
this.numNewStarredProjectChanges =
|
||||||
|
filteredNewStarredProjectChanges.length;
|
||||||
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
|
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Don't show errors for starred project changes as it's a secondary feature
|
// Don't show errors for starred project changes as it's a secondary feature
|
||||||
|
|||||||
@@ -284,7 +284,10 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>The changes did not affect essential project data.</div>
|
<div v-else>
|
||||||
|
The changes are not important, like it was saved by accident or
|
||||||
|
you've seen it all before.
|
||||||
|
</div>
|
||||||
<!-- New line that appears on hover -->
|
<!-- New line that appears on hover -->
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
|
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
|
||||||
@@ -589,13 +592,13 @@ export default class NewActivityView extends Vue {
|
|||||||
|
|
||||||
for (const planChange of planChanges) {
|
for (const planChange of planChanges) {
|
||||||
const currentPlan: PlanSummaryRecord = planChange.plan;
|
const currentPlan: PlanSummaryRecord = planChange.plan;
|
||||||
const wrappedClaim: GenericCredWrapper<PlanActionClaim> =
|
const wrappedClaim: GenericCredWrapper<PlanActionClaim> | undefined =
|
||||||
planChange.wrappedClaimBefore;
|
planChange.wrappedClaimBefore;
|
||||||
|
|
||||||
// Extract the actual claim from the wrapped claim
|
// Extract the actual claim from the wrapped claim
|
||||||
let previousClaim: PlanActionClaim;
|
let previousClaim: PlanActionClaim | undefined;
|
||||||
|
|
||||||
const embeddedClaim: PlanActionClaim = wrappedClaim.claim;
|
const embeddedClaim: PlanActionClaim | undefined = wrappedClaim?.claim;
|
||||||
if (
|
if (
|
||||||
embeddedClaim &&
|
embeddedClaim &&
|
||||||
typeof embeddedClaim === "object" &&
|
typeof embeddedClaim === "object" &&
|
||||||
@@ -609,7 +612,9 @@ export default class NewActivityView extends Vue {
|
|||||||
previousClaim = embeddedClaim;
|
previousClaim = embeddedClaim;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!previousClaim || !currentPlan.handleId) {
|
if (!previousClaim) {
|
||||||
|
// Can happen when a project is starred after the stored last-seen-change-jwt ID
|
||||||
|
// so we'll just leave the message saying there are no important differences.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,60 @@
|
|||||||
</div>
|
</div>
|
||||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||||
|
|
||||||
<input
|
<!-- Authorized Representative Selection -->
|
||||||
v-model="agentDid"
|
<div class="w-full flex items-stretch my-4">
|
||||||
type="text"
|
<div
|
||||||
placeholder="Other Authorized Representative"
|
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
|
||||||
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
|
@click="openRepresentativeDialog"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<EntityIcon
|
||||||
|
v-if="selectedRepresentative"
|
||||||
|
:contact="selectedRepresentative"
|
||||||
|
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||||
/>
|
/>
|
||||||
|
<font-awesome v-else icon="user" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'text-sm font-semibold': selectedRepresentative,
|
||||||
|
'text-slate-400': !selectedRepresentative,
|
||||||
|
}"
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
selectedRepresentative
|
||||||
|
? selectedRepresentative.name || AppString.NO_CONTACT_NAME
|
||||||
|
: "Assign Authorized Representative…"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectedRepresentative"
|
||||||
|
class="text-xs text-slate-500 truncate"
|
||||||
|
>
|
||||||
|
{{ agentDid }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="selectedRepresentative"
|
||||||
|
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
|
||||||
|
@click="unsetRepresentative"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProjectRepresentativeDialog
|
||||||
|
ref="representativeDialog"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:notify="$notify"
|
||||||
|
@assign="handleRepresentativeAssigned"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p v-if="shouldShowOwnershipWarning">
|
<p v-if="shouldShowOwnershipWarning">
|
||||||
<span class="text-red-500">Beware!</span>
|
<span class="text-red-500">Beware!</span>
|
||||||
@@ -232,9 +280,12 @@ import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
|||||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||||
import { LeafletMouseEvent } from "leaflet";
|
import { LeafletMouseEvent } from "leaflet";
|
||||||
|
|
||||||
|
import EntityIcon from "../components/EntityIcon.vue";
|
||||||
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
||||||
|
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import {
|
import {
|
||||||
|
AppString,
|
||||||
DEFAULT_IMAGE_API_SERVER,
|
DEFAULT_IMAGE_API_SERVER,
|
||||||
DEFAULT_PARTNER_API_SERVER,
|
DEFAULT_PARTNER_API_SERVER,
|
||||||
NotificationIface,
|
NotificationIface,
|
||||||
@@ -268,6 +319,7 @@ import {
|
|||||||
retrieveAccountCount,
|
retrieveAccountCount,
|
||||||
retrieveFullyDecryptedAccount,
|
retrieveFullyDecryptedAccount,
|
||||||
} from "../libs/util";
|
} from "../libs/util";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EventTemplate,
|
EventTemplate,
|
||||||
@@ -323,7 +375,15 @@ import { logger } from "../utils/logger";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
|
components: {
|
||||||
|
EntityIcon,
|
||||||
|
ImageMethodDialog,
|
||||||
|
ProjectRepresentativeDialog,
|
||||||
|
LMap,
|
||||||
|
LMarker,
|
||||||
|
LTileLayer,
|
||||||
|
QuickNav,
|
||||||
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
export default class NewEditProjectView extends Vue {
|
export default class NewEditProjectView extends Vue {
|
||||||
@@ -334,6 +394,9 @@ export default class NewEditProjectView extends Vue {
|
|||||||
// Notification helpers
|
// Notification helpers
|
||||||
private notify!: ReturnType<typeof createNotifyHelpers>;
|
private notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
AppString = AppString;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display error notification to user
|
* Display error notification to user
|
||||||
* Provides consistent error messaging with 5-second timeout
|
* Provides consistent error messaging with 5-second timeout
|
||||||
@@ -346,6 +409,8 @@ export default class NewEditProjectView extends Vue {
|
|||||||
// Component state properties
|
// Component state properties
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
agentDid = "";
|
agentDid = "";
|
||||||
|
allContacts: Array<Contact> = [];
|
||||||
|
allMyDids: string[] = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
endDateInput?: string;
|
endDateInput?: string;
|
||||||
endTimeInput?: string;
|
endTimeInput?: string;
|
||||||
@@ -392,16 +457,24 @@ export default class NewEditProjectView extends Vue {
|
|||||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||||
this.activeDid = activeIdentity.activeDid || "";
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
|
|
||||||
|
// Get all user's DIDs
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.allMyDids = await (this as any).$getAllAccountDids();
|
||||||
|
|
||||||
|
// Load contacts sorted by date added (newest first) for consistent "Recently Added" display
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.allContacts = await (this as any).$contactsByDateAdded();
|
||||||
|
|
||||||
this.apiServer = settings.apiServer || "";
|
this.apiServer = settings.apiServer || "";
|
||||||
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
||||||
|
|
||||||
this.projectId = (this.$route.query["projectId"] as string) || "";
|
this.projectId = (this.$route.query["projectId"] as string) || "";
|
||||||
|
|
||||||
if (this.projectId) {
|
if (this.isSavedProject()) {
|
||||||
if (this.numAccounts === 0) {
|
if (this.numAccounts === 0) {
|
||||||
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
|
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
|
||||||
} else {
|
} else {
|
||||||
this.loadProject(this.activeDid);
|
this.loadProject(this.activeDid, this.projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,11 +484,9 @@ export default class NewEditProjectView extends Vue {
|
|||||||
* Retrieves project information from the API and populates form fields
|
* Retrieves project information from the API and populates form fields
|
||||||
* @param userDid - User's decentralized identifier
|
* @param userDid - User's decentralized identifier
|
||||||
*/
|
*/
|
||||||
async loadProject(userDid: string) {
|
async loadProject(userDid: string, projectId: string) {
|
||||||
const url =
|
const url =
|
||||||
this.apiServer +
|
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
|
||||||
"/api/claim/byHandle/" +
|
|
||||||
encodeURIComponent(this.projectId);
|
|
||||||
const headers = await getHeaders(userDid);
|
const headers = await getHeaders(userDid);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -432,6 +503,12 @@ export default class NewEditProjectView extends Vue {
|
|||||||
}
|
}
|
||||||
if (this.fullClaim?.agent?.identifier) {
|
if (this.fullClaim?.agent?.identifier) {
|
||||||
this.agentDid = this.fullClaim.agent.identifier;
|
this.agentDid = this.fullClaim.agent.identifier;
|
||||||
|
if (this.activeDid !== this.projectIssuerDid) {
|
||||||
|
this.agentDid = this.projectIssuerDid;
|
||||||
|
this.notify.warning(
|
||||||
|
"You were previously the agent, so the agent has been set to the previous owner. You can change it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.fullClaim.startTime) {
|
if (this.fullClaim.startTime) {
|
||||||
const localDateTime = DateTime.fromISO(
|
const localDateTime = DateTime.fromISO(
|
||||||
@@ -536,7 +613,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
private async saveProject() {
|
private async saveProject() {
|
||||||
// Make a claim
|
// Make a claim
|
||||||
const vcClaim: PlanActionClaim = this.fullClaim;
|
const vcClaim: PlanActionClaim = this.fullClaim;
|
||||||
if (this.projectId) {
|
if (this.isSavedProject()) {
|
||||||
vcClaim.lastClaimId = this.lastClaimJwtId;
|
vcClaim.lastClaimId = this.lastClaimJwtId;
|
||||||
}
|
}
|
||||||
if (this.agentDid) {
|
if (this.agentDid) {
|
||||||
@@ -870,6 +947,10 @@ export default class NewEditProjectView extends Vue {
|
|||||||
this.longitude = event.latlng.lng;
|
this.longitude = event.latlng.lng;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSavedProject(): boolean {
|
||||||
|
return !!this.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed property for character count display
|
* Computed property for character count display
|
||||||
* Shows current description length and maximum character limit
|
* Shows current description length and maximum character limit
|
||||||
@@ -885,6 +966,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
*/
|
*/
|
||||||
get shouldShowOwnershipWarning(): boolean {
|
get shouldShowOwnershipWarning(): boolean {
|
||||||
return (
|
return (
|
||||||
|
this.isSavedProject() &&
|
||||||
this.activeDid !== this.projectIssuerDid &&
|
this.activeDid !== this.projectIssuerDid &&
|
||||||
this.agentDid !== this.projectIssuerDid
|
this.agentDid !== this.projectIssuerDid
|
||||||
);
|
);
|
||||||
@@ -961,5 +1043,37 @@ export default class NewEditProjectView extends Vue {
|
|||||||
get shouldShowSpinner(): boolean {
|
get shouldShowSpinner(): boolean {
|
||||||
return !this.isHiddenSpinner;
|
return !this.isHiddenSpinner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for selected representative contact
|
||||||
|
* Derives the contact from agentDid by finding it in allContacts
|
||||||
|
*/
|
||||||
|
get selectedRepresentative(): Contact | null {
|
||||||
|
if (!this.agentDid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.allContacts.find((c) => c.did === this.agentDid) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the representative selection dialog
|
||||||
|
*/
|
||||||
|
openRepresentativeDialog(): void {
|
||||||
|
(this.$refs.representativeDialog as ProjectRepresentativeDialog).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle representative assignment from dialog
|
||||||
|
*/
|
||||||
|
handleRepresentativeAssigned(contact: Contact): void {
|
||||||
|
this.agentDid = contact.did;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unset the representative and revert to initial state
|
||||||
|
*/
|
||||||
|
unsetRepresentative(): void {
|
||||||
|
this.agentDid = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -186,16 +186,59 @@
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="projectLink"
|
for="projectLink"
|
||||||
class="block text-sm font-medium text-gray-700"
|
class="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>Project Link</label
|
>Project Link</label
|
||||||
>
|
>
|
||||||
<input
|
<div class="w-full flex items-stretch">
|
||||||
id="projectLink"
|
<div
|
||||||
v-model="newOrUpdatedMeetingInputs.projectLink"
|
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
|
||||||
type="text"
|
@click="openProjectLinkDialog"
|
||||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
>
|
||||||
placeholder="Project ID"
|
<div>
|
||||||
|
<ProjectIcon
|
||||||
|
v-if="selectedProject"
|
||||||
|
:entity-id="selectedProject.handleId"
|
||||||
|
:icon-size="30"
|
||||||
|
:image-url="selectedProject.image"
|
||||||
|
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||||
/>
|
/>
|
||||||
|
<font-awesome
|
||||||
|
v-else
|
||||||
|
icon="folder-open"
|
||||||
|
class="text-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'text-sm font-semibold': selectedProject,
|
||||||
|
'text-slate-400': !selectedProject,
|
||||||
|
}"
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
selectedProject
|
||||||
|
? selectedProject.name || "Unnamed Project"
|
||||||
|
: "Select Project…"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectedProject"
|
||||||
|
class="text-xs text-slate-500 truncate"
|
||||||
|
>
|
||||||
|
<font-awesome icon="user" class="text-slate-400" />
|
||||||
|
{{ selectedProjectIssuerName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="selectedProject"
|
||||||
|
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
|
||||||
|
@click="unsetProjectLink"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -224,6 +267,17 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MeetingProjectDialog
|
||||||
|
ref="meetingProjectDialog"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:notify="$notify"
|
||||||
|
@assign="handleProjectLinkAssigned"
|
||||||
|
@open="handleDialogOpen"
|
||||||
|
@close="handleDialogClose"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Members Section -->
|
<!-- Members Section -->
|
||||||
<div
|
<div
|
||||||
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
||||||
@@ -254,6 +308,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<MembersList
|
<MembersList
|
||||||
|
ref="membersList"
|
||||||
:password="currentMeeting.password || ''"
|
:password="currentMeeting.password || ''"
|
||||||
:show-organizer-tools="true"
|
:show-organizer-tools="true"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
@@ -292,10 +347,13 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
import MembersList from "../components/MembersList.vue";
|
import MembersList from "../components/MembersList.vue";
|
||||||
|
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
||||||
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||||
import {
|
import {
|
||||||
errorStringForLog,
|
errorStringForLog,
|
||||||
getHeaders,
|
getHeaders,
|
||||||
serverMessageForUser,
|
serverMessageForUser,
|
||||||
|
didInfo,
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import { encryptMessage } from "../libs/crypto";
|
import { encryptMessage } from "../libs/crypto";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
@@ -309,6 +367,8 @@ import {
|
|||||||
NOTIFY_MEETING_DELETED,
|
NOTIFY_MEETING_DELETED,
|
||||||
NOTIFY_MEETING_LINK_COPIED,
|
NOTIFY_MEETING_LINK_COPIED,
|
||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
|
import { PlanData } from "../interfaces/records";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
interface ServerMeeting {
|
interface ServerMeeting {
|
||||||
groupId: number; // from the server
|
groupId: number; // from the server
|
||||||
name: string; // to & from the server
|
name: string; // to & from the server
|
||||||
@@ -331,6 +391,8 @@ interface MeetingSetupInputs {
|
|||||||
QuickNav,
|
QuickNav,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
MembersList,
|
MembersList,
|
||||||
|
MeetingProjectDialog,
|
||||||
|
ProjectIcon,
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
@@ -354,6 +416,9 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
showDeleteConfirm = false;
|
showDeleteConfirm = false;
|
||||||
fullName = "";
|
fullName = "";
|
||||||
|
allContacts: Contact[] = [];
|
||||||
|
allMyDids: string[] = [];
|
||||||
|
selectedProjectData: PlanData | null = null;
|
||||||
get minDateTime() {
|
get minDateTime() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
|
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
|
||||||
@@ -370,7 +435,17 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
this.fullName = settings?.firstName || "";
|
this.fullName = settings?.firstName || "";
|
||||||
this.isRegistered = !!settings?.isRegistered;
|
this.isRegistered = !!settings?.isRegistered;
|
||||||
|
|
||||||
|
// Load contacts and DIDs for dialog
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.allContacts = await (this as any).$contactsByDateAdded();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.allMyDids = await (this as any).$getAllAccountDids();
|
||||||
|
|
||||||
await this.fetchCurrentMeeting();
|
await this.fetchCurrentMeeting();
|
||||||
|
|
||||||
|
// Ensure selected project is loaded if projectLink exists
|
||||||
|
await this.ensureSelectedProjectLoaded();
|
||||||
|
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +517,54 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the selected project is loaded if projectLink exists
|
||||||
|
*/
|
||||||
|
async ensureSelectedProjectLoaded(): Promise<void> {
|
||||||
|
const projectLink =
|
||||||
|
this.currentMeeting?.projectLink ||
|
||||||
|
this.newOrUpdatedMeetingInputs?.projectLink;
|
||||||
|
|
||||||
|
if (!projectLink) {
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.fetchProjectByHandleId(projectLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single project by handleId
|
||||||
|
* @param handleId - The project handleId to fetch
|
||||||
|
*/
|
||||||
|
async fetchProjectByHandleId(handleId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const url = `${this.apiServer}/api/v2/report/plans?handleId=${encodeURIComponent(handleId)}`;
|
||||||
|
const resp = await this.axios.get(url, { headers });
|
||||||
|
|
||||||
|
if (resp.status === 200 && resp.data.data && resp.data.data.length > 0) {
|
||||||
|
const project = resp.data.data[0];
|
||||||
|
this.selectedProjectData = {
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
image: project.image,
|
||||||
|
handleId: project.handleId,
|
||||||
|
issuerDid: project.issuerDid,
|
||||||
|
rowId: project.rowId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(
|
||||||
|
"Error fetching project by handleId: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createMeeting() {
|
async createMeeting() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
@@ -576,7 +699,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startEditing() {
|
async startEditing() {
|
||||||
// Populate form with existing meeting data
|
// Populate form with existing meeting data
|
||||||
if (this.currentMeeting) {
|
if (this.currentMeeting) {
|
||||||
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
||||||
@@ -587,6 +710,10 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
password: this.currentMeeting.password || "",
|
password: this.currentMeeting.password || "",
|
||||||
projectLink: this.currentMeeting.projectLink || "",
|
projectLink: this.currentMeeting.projectLink || "",
|
||||||
};
|
};
|
||||||
|
// Ensure selected project is loaded if projectLink exists
|
||||||
|
if (this.currentMeeting.projectLink) {
|
||||||
|
await this.ensureSelectedProjectLoaded();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.$logError(
|
this.$logError(
|
||||||
"There is no current meeting to edit. We should never get here.",
|
"There is no current meeting to edit. We should never get here.",
|
||||||
@@ -594,9 +721,15 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEditing() {
|
async cancelEditing() {
|
||||||
// Reset form data
|
// Reset form data
|
||||||
this.newOrUpdatedMeetingInputs = null;
|
this.newOrUpdatedMeetingInputs = null;
|
||||||
|
// Restore selected project from currentMeeting if it exists
|
||||||
|
if (this.currentMeeting?.projectLink) {
|
||||||
|
await this.ensureSelectedProjectLoaded();
|
||||||
|
} else {
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMeeting() {
|
async updateMeeting() {
|
||||||
@@ -710,5 +843,78 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
this.notify.error("Failed to copy meeting link to clipboard.");
|
this.notify.error("Failed to copy meeting link to clipboard.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for selected project
|
||||||
|
* Returns the separately stored selected project data
|
||||||
|
*/
|
||||||
|
get selectedProject(): PlanData | null {
|
||||||
|
return this.selectedProjectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for selected project issuer display name
|
||||||
|
* Uses didInfo to format the issuer name similar to ProjectCard
|
||||||
|
*/
|
||||||
|
get selectedProjectIssuerName(): string {
|
||||||
|
if (!this.selectedProject) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return didInfo(
|
||||||
|
this.selectedProject.issuerDid,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the project link selection dialog
|
||||||
|
*/
|
||||||
|
openProjectLinkDialog(): void {
|
||||||
|
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle project assignment from dialog
|
||||||
|
*/
|
||||||
|
handleProjectLinkAssigned(project: PlanData): void {
|
||||||
|
// Store the selected project directly
|
||||||
|
this.selectedProjectData = project;
|
||||||
|
|
||||||
|
if (this.newOrUpdatedMeetingInputs) {
|
||||||
|
this.newOrUpdatedMeetingInputs.projectLink = project.handleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unset the project link and revert to initial state
|
||||||
|
*/
|
||||||
|
unsetProjectLink(): void {
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
if (this.newOrUpdatedMeetingInputs) {
|
||||||
|
this.newOrUpdatedMeetingInputs.projectLink = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle dialog open event - stop auto-refresh in MembersList
|
||||||
|
*/
|
||||||
|
handleDialogOpen(): void {
|
||||||
|
const membersList = this.$refs.membersList as MembersList;
|
||||||
|
if (membersList) {
|
||||||
|
membersList.stopAutoRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle dialog close event - start auto-refresh in MembersList
|
||||||
|
*/
|
||||||
|
handleDialogClose(): void {
|
||||||
|
const membersList = this.$refs.membersList as MembersList;
|
||||||
|
if (membersList) {
|
||||||
|
membersList.startAutoRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
<button :class="sqlLinkClasses" @click="setAccountsQuery">
|
<button :class="sqlLinkClasses" @click="setAccountsQuery">
|
||||||
Accounts
|
Accounts
|
||||||
</button>
|
</button>
|
||||||
|
<button :class="sqlLinkClasses" @click="setActiveIdentityQuery">
|
||||||
|
Active DID
|
||||||
|
</button>
|
||||||
<button :class="sqlLinkClasses" @click="setContactsQuery">
|
<button :class="sqlLinkClasses" @click="setContactsQuery">
|
||||||
Contacts
|
Contacts
|
||||||
</button>
|
</button>
|
||||||
@@ -525,6 +528,11 @@ export default class Help extends Vue {
|
|||||||
this.executeSql();
|
this.executeSql();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActiveIdentityQuery() {
|
||||||
|
this.sqlQuery = "SELECT * FROM active_identity;";
|
||||||
|
this.executeSql();
|
||||||
|
}
|
||||||
|
|
||||||
setContactsQuery() {
|
setContactsQuery() {
|
||||||
this.sqlQuery = "SELECT * FROM contacts;";
|
this.sqlQuery = "SELECT * FROM contacts;";
|
||||||
this.executeSql();
|
this.executeSql();
|
||||||
|
|||||||
@@ -54,6 +54,108 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Nearest Neighbors Section -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
|
||||||
|
profile.issuerDid !== neighbors?.[0]?.did // and they're not directly connected (since there's no in-between)
|
||||||
|
"
|
||||||
|
class="mt-6"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold mb-3">Network Connections</h2>
|
||||||
|
|
||||||
|
<div v-if="loadingNeighbors">
|
||||||
|
<div class="flex justify-center items-center py-8">
|
||||||
|
<font-awesome
|
||||||
|
icon="spinner"
|
||||||
|
class="fa-spin-pulse text-2xl text-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="neighborsError"
|
||||||
|
class="bg-red-50 border border-red-300 rounded-md p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<font-awesome
|
||||||
|
icon="exclamation-triangle"
|
||||||
|
class="text-red-500 mt-0.5"
|
||||||
|
/>
|
||||||
|
<p class="text-red-700">{{ neighborsError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="neighbor in neighbors"
|
||||||
|
:key="neighbor.did"
|
||||||
|
class="bg-slate-50 border border-slate-300 rounded-md"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 p-3">
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<button
|
||||||
|
title="Copy profile link and expand"
|
||||||
|
class="text-blue-600 flex-shrink-0"
|
||||||
|
@click="onNeighborExpandClick(neighbor.did)"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
:icon="
|
||||||
|
expandedNeighborDid === neighbor.did
|
||||||
|
? 'chevron-down'
|
||||||
|
: 'chevron-right'
|
||||||
|
"
|
||||||
|
class="text-sm"
|
||||||
|
/>
|
||||||
|
{{ getNeighborDisplayName(neighbor.did) }}
|
||||||
|
</button>
|
||||||
|
<span :class="getRelationBadgeClass(neighbor.relation)">
|
||||||
|
{{ getRelationLabel(neighbor.relation) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="expandedNeighborDid === neighbor.did"
|
||||||
|
class="border-t border-slate-300 p-3 bg-white"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'did', params: { did: neighbor.did } }"
|
||||||
|
class="text-blue-600 hover:text-blue-800 font-medium underline"
|
||||||
|
>
|
||||||
|
Go to contact info
|
||||||
|
</router-link>
|
||||||
|
and send them the link in your clipboard and ask for an
|
||||||
|
introduction to this person.
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
getNeighborDisplayName(neighbor.did) === '' ||
|
||||||
|
neighborIsNotInContacts(neighbor.did)
|
||||||
|
"
|
||||||
|
class="flex flex-col gap-1 mt-2"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-slate-600">
|
||||||
|
This person is connected to you, but they are not in this
|
||||||
|
device's contacts. Copy this DID link and check on another
|
||||||
|
device or check with different people.
|
||||||
|
</p>
|
||||||
|
<span class="flex items-center gap-1 min-w-0">
|
||||||
|
<span class="text-xs truncate text-slate-600 min-w-0">
|
||||||
|
{{ neighbor.did }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
title="Copy DID Link"
|
||||||
|
class="text-blue-600 hover:text-blue-800 underline cursor-pointer flex-shrink-0"
|
||||||
|
@click.prevent="onCopyDidClick(neighbor.did)"
|
||||||
|
>
|
||||||
|
<font-awesome icon="copy" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Map for first coordinates -->
|
<!-- Map for first coordinates -->
|
||||||
<div v-if="hasFirstLocation" class="mt-4">
|
<div v-if="hasFirstLocation" class="mt-4">
|
||||||
<h2 class="text-lg font-semibold">Location</h2>
|
<h2 class="text-lg font-semibold">Location</h2>
|
||||||
@@ -159,7 +261,11 @@ export default class UserProfileView extends Vue {
|
|||||||
activeDid = "";
|
activeDid = "";
|
||||||
allContacts: Array<Contact> = [];
|
allContacts: Array<Contact> = [];
|
||||||
allMyDids: Array<string> = [];
|
allMyDids: Array<string> = [];
|
||||||
|
expandedNeighborDid: string | null = null;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
loadingNeighbors = false;
|
||||||
|
neighbors: Array<{ did: string; relation: string }> = [];
|
||||||
|
neighborsError = "";
|
||||||
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
||||||
profile: UserProfile | null = null;
|
profile: UserProfile | null = null;
|
||||||
|
|
||||||
@@ -183,8 +289,8 @@ export default class UserProfileView extends Vue {
|
|||||||
*/
|
*/
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.initializeSettings();
|
await this.initializeSettings();
|
||||||
await this.loadContacts();
|
|
||||||
await this.loadProfile();
|
await this.loadProfile();
|
||||||
|
await this.loadNeighbors();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -199,12 +305,7 @@ export default class UserProfileView extends Vue {
|
|||||||
this.activeDid = activeIdentity.activeDid || "";
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
|
|
||||||
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads all contacts from database
|
|
||||||
*/
|
|
||||||
private async loadContacts() {
|
|
||||||
this.allContacts = await this.$getAllContacts();
|
this.allContacts = await this.$getAllContacts();
|
||||||
this.allMyDids = await retrieveAccountDids();
|
this.allMyDids = await retrieveAccountDids();
|
||||||
}
|
}
|
||||||
@@ -249,23 +350,100 @@ export default class UserProfileView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copies profile link to clipboard
|
* Loads nearest neighbors from partner API
|
||||||
*
|
*
|
||||||
* Creates a deep link to the profile and copies it to the clipboard
|
* Fetches network connections for the profile and displays them
|
||||||
* Shows success notification when completed
|
* with appropriate relation labels
|
||||||
|
*/
|
||||||
|
async loadNeighbors() {
|
||||||
|
const profileId: string = this.$route.params.id as string;
|
||||||
|
if (!profileId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadingNeighbors = true;
|
||||||
|
this.neighborsError = "";
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.partnerApiServer}/api/partner/userProfileNearestNeighbors/${encodeURIComponent(profileId)}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: await getHeaders(this.activeDid),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
const result = await response.json();
|
||||||
|
this.neighbors = result.data;
|
||||||
|
this.neighborsError = "";
|
||||||
|
} else {
|
||||||
|
logger.warn("Failed to load neighbors:", response.status);
|
||||||
|
this.neighbors = [];
|
||||||
|
this.neighborsError = "Failed to load network connections.";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading neighbors:", error);
|
||||||
|
this.neighbors = [];
|
||||||
|
this.neighborsError =
|
||||||
|
"An error occurred while loading network connections.";
|
||||||
|
} finally {
|
||||||
|
this.loadingNeighbors = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a deep link to the profile to the clipboard
|
||||||
*/
|
*/
|
||||||
async onCopyLinkClick() {
|
async onCopyLinkClick() {
|
||||||
// Use production URL for sharing to avoid localhost issues in development
|
|
||||||
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
|
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
|
||||||
try {
|
try {
|
||||||
await copyToClipboard(deepLink);
|
await copyToClipboard(deepLink);
|
||||||
this.notify.copied("profile link", TIMEOUTS.STANDARD);
|
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.$logAndConsole(`Error copying profile link: ${error}`, true);
|
this.$logAndConsole(`Error copying profile link: ${error}`, true);
|
||||||
this.notify.error("Failed to copy profile link.");
|
this.notify.error("Failed to copy profile link.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a deep link to the provided DID to the clipboard
|
||||||
|
*/
|
||||||
|
async onCopyDidClick(did: string) {
|
||||||
|
const deepLink = `${APP_SERVER}/deep-link/did/${encodeURIComponent(did)}`;
|
||||||
|
try {
|
||||||
|
await copyToClipboard(deepLink);
|
||||||
|
this.notify.copied("DID link", TIMEOUTS.STANDARD);
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(`Error copying DID link: ${error}`, true);
|
||||||
|
this.notify.error("Failed to copy DID link.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles clicking the expand button next to a neighbor's name
|
||||||
|
* Copies the profile link to clipboard and toggles the expanded section
|
||||||
|
*/
|
||||||
|
async onNeighborExpandClick(did: string) {
|
||||||
|
if (this.expandedNeighborDid === did) {
|
||||||
|
this.expandedNeighborDid = null;
|
||||||
|
// don't copy the link
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the profile link
|
||||||
|
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
|
||||||
|
try {
|
||||||
|
await copyToClipboard(deepLink);
|
||||||
|
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(`Error copying profile link: ${error}`, true);
|
||||||
|
this.notify.error("Failed to copy profile link.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the expanded section
|
||||||
|
this.expandedNeighborDid = did;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed properties for template logic streamlining
|
* Computed properties for template logic streamlining
|
||||||
*/
|
*/
|
||||||
@@ -330,5 +508,64 @@ export default class UserProfileView extends Vue {
|
|||||||
get tileLayerUrl() {
|
get tileLayerUrl() {
|
||||||
return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets display name for a neighbor's DID
|
||||||
|
* Uses didInfo utility to show contact name if available, otherwise DID
|
||||||
|
* @param did - The DID to get display name for
|
||||||
|
* @returns Formatted display name
|
||||||
|
*/
|
||||||
|
getNeighborDisplayName(did: string): string {
|
||||||
|
return this.didInfo(did, this.activeDid, this.allMyDids, this.allContacts);
|
||||||
|
}
|
||||||
|
|
||||||
|
neighborIsNotInContacts(did: string) {
|
||||||
|
return !this.allContacts.some((contact) => contact.did === did);
|
||||||
|
}
|
||||||
|
|
||||||
|
noNeighborsAreInContacts() {
|
||||||
|
return this.neighbors.every(
|
||||||
|
(neighbor) =>
|
||||||
|
!this.allContacts.some((contact) => contact.did === neighbor.did),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets human-readable label for relation type
|
||||||
|
* @param relation - The relation type from API
|
||||||
|
* @returns Display label for the relation
|
||||||
|
*/
|
||||||
|
getRelationLabel(relation: string): string {
|
||||||
|
switch (relation) {
|
||||||
|
case "REGISTERED_BY_YOU":
|
||||||
|
return "Registered by You";
|
||||||
|
case "REGISTERED_YOU":
|
||||||
|
return "Registered You";
|
||||||
|
case "TARGET":
|
||||||
|
return "Yourself";
|
||||||
|
default:
|
||||||
|
return relation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets CSS classes for relation badge styling
|
||||||
|
* @param relation - The relation type from API
|
||||||
|
* @returns CSS class string for badge
|
||||||
|
*/
|
||||||
|
getRelationBadgeClass(relation: string): string {
|
||||||
|
const baseClasses =
|
||||||
|
"text-xs font-semibold px-2 py-1 rounded whitespace-nowrap";
|
||||||
|
switch (relation) {
|
||||||
|
case "REGISTERED_BY_YOU":
|
||||||
|
return `${baseClasses} bg-blue-100 text-blue-700`;
|
||||||
|
case "REGISTERED_YOU":
|
||||||
|
return `${baseClasses} bg-green-100 text-green-700`;
|
||||||
|
case "TARGET":
|
||||||
|
return `${baseClasses} bg-purple-100 text-purple-700`;
|
||||||
|
default:
|
||||||
|
return `${baseClasses} bg-slate-100 text-slate-700`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user