Browse Source

Merge branch 'master' into integrate-notification-plugin

pull/214/head
Matthew Raymer 3 days ago
parent
commit
9ea9f4969e
  1. 13
      BUILDING.md
  2. 6
      CHANGELOG.md
  3. 4
      android/app/build.gradle
  4. 8
      ios/App/App.xcodeproj/project.pbxproj
  5. 4
      package-lock.json
  6. 2
      package.json
  7. 30
      scripts/build-android.sh
  8. 28
      scripts/build-ios.sh
  9. 2
      src/assets/styles/tailwind.css
  10. 91
      src/components/BulkMembersDialog.vue
  11. 398
      src/components/EntityGrid.vue
  12. 69
      src/components/EntitySelectionStep.vue
  13. 4
      src/components/GiftedDialog.vue
  14. 1
      src/components/MembersList.vue
  15. 34
      src/components/PersonCard.vue
  16. 17
      src/components/ProjectCard.vue
  17. 66
      src/components/ShowAllCard.vue
  18. 13
      src/components/SpecialEntityCard.vue
  19. 5
      src/libs/endorserServer.ts
  20. 78
      src/services/platforms/CapacitorPlatformService.ts
  21. 16
      src/test/EntityGridFunctionPropTest.vue
  22. 15
      src/utils/PlatformServiceMixin.ts

13
BUILDING.md

@ -1161,7 +1161,7 @@ If you need to build manually or want to understand the individual steps:
##### 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 46 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.1;/g" App.xcodeproj/project.pbxproj && cd - 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 -
# 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
``` ```
@ -1197,7 +1197,8 @@ If you need to build manually or want to understand the individual steps:
- It can take 15 minutes for the build to show up in the list of builds. - It can take 15 minutes for the build to show up in the list of builds.
- You'll probably have to "Manage" something about encryption, disallowed in France. - You'll probably have to "Manage" something about encryption, disallowed in France.
- Then "Save" and "Add to Review" and "Resubmit to App Review". - Then "Save" and "Add to Review" and "Resubmit to App Review".
- Eventually it'll be "Ready for Distribution" which means - Eventually it'll be "Ready for Distribution" which means it's live
- When finished, bump package.json version
### Android Build ### Android Build
@ -1315,11 +1316,11 @@ The recommended way to build for Android is using the automated build script:
#### Android Manual Build Process #### Android Manual Build Process
##### 1. Bump the version in package.json, then here: android/app/build.gradle ##### 1. Bump the version in package.json, then update these versions & run:
```bash ```bash
perl -p -i -e 's/versionCode .*/versionCode 46/g' android/app/build.gradle perl -p -i -e 's/versionCode .*/versionCode 47/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.1"/g' android/app/build.gradle perl -p -i -e 's/versionName .*/versionName "1.1.2"/g' android/app/build.gradle
``` ```
##### 2. Build ##### 2. Build
@ -1379,6 +1380,8 @@ At play.google.com/console:
- Note that if you add testers, you have to go to "Publishing Overview" and send - Note that if you add testers, you have to go to "Publishing Overview" and send
those changes or your (closed) testers won't see it. those changes or your (closed) testers won't see it.
- When finished, bump package.json version
### Capacitor Operations ### Capacitor Operations
```bash ```bash

6
CHANGELOG.md

@ -6,6 +6,12 @@ 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
### Fixed
- Bad page when user follows prompt to backup seed
## [1.1.1] - 2025.11.03 ## [1.1.1] - 2025.11.03
### Added ### Added

4
android/app/build.gradle

@ -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 46 versionCode 47
versionName "1.1.1" versionName "1.1.2"
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.

8
ios/App/App.xcodeproj/project.pbxproj

@ -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 = 46; CURRENT_PROJECT_VERSION = 47;
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.1; MARKETING_VERSION = 1.1.2;
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 = 46; CURRENT_PROJECT_VERSION = 47;
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.1; MARKETING_VERSION = 1.1.2;
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 = "";

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.2-beta", "version": "1.1.3-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "timesafari", "name": "timesafari",
"version": "1.1.2-beta", "version": "1.1.3-beta",
"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",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.2-beta", "version": "1.1.3-beta",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"

30
scripts/build-android.sh

@ -436,7 +436,21 @@ fi
log_info "Cleaning dist directory..." log_info "Cleaning dist directory..."
clean_build_artifacts "dist" clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode # Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3 safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then elif [ "$BUILD_MODE" = "test" ]; then
@ -445,23 +459,23 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3 safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi fi
# Step 5: Clean Gradle build # Step 6: Clean Gradle build
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4 safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
# Step 6: Build based on type # Step 7: Build based on type
if [ "$BUILD_TYPE" = "debug" ]; then if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5 safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
elif [ "$BUILD_TYPE" = "release" ]; then elif [ "$BUILD_TYPE" = "release" ]; then
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5 safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
fi fi
# Step 7: Sync with Capacitor # Step 8: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6 safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# Step 8: Generate assets # Step 9: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7 safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
# Step 9: Build APK/AAB if requested # Step 10: Build APK/AAB if requested
if [ "$BUILD_APK" = true ]; then if [ "$BUILD_APK" = true ]; then
if [ "$BUILD_TYPE" = "debug" ]; then if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5 safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
@ -474,7 +488,7 @@ if [ "$BUILD_AAB" = true ]; then
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5 safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
fi fi
# Step 10: Auto-run app if requested # Step 11: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then if [ "$AUTO_RUN" = true ]; then
log_step "Auto-running Android app..." log_step "Auto-running Android app..."
safe_execute "Launching app" "npx cap run android" || { safe_execute "Launching app" "npx cap run android" || {
@ -485,7 +499,7 @@ if [ "$AUTO_RUN" = true ]; then
log_success "Android app launched successfully!" log_success "Android app launched successfully!"
fi fi
# Step 11: Open Android Studio if requested # Step 12: Open Android Studio if requested
if [ "$OPEN_STUDIO" = true ]; then if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Android Studio" "npx cap open android" || exit 8 safe_execute "Opening Android Studio" "npx cap open android" || exit 8
fi fi

28
scripts/build-ios.sh

@ -381,7 +381,21 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
log_info "Cleaning dist directory..." log_info "Cleaning dist directory..."
clean_build_artifacts "dist" clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode # Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3 safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then elif [ "$BUILD_MODE" = "test" ]; then
@ -390,16 +404,16 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3 safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi fi
# Step 5: Sync with Capacitor # Step 6: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6 safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# Step 6: Generate assets # Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7 safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
# Step 7: Build iOS app # Step 8: Build iOS app
safe_execute "Building iOS app" "build_ios_app" || exit 5 safe_execute "Building iOS app" "build_ios_app" || exit 5
# Step 8: Build IPA/App if requested # Step 9: Build IPA/App if requested
if [ "$BUILD_IPA" = true ]; then if [ "$BUILD_IPA" = true ]; then
log_info "Building IPA package..." log_info "Building IPA package..."
cd ios/App cd ios/App
@ -426,12 +440,12 @@ if [ "$BUILD_APP" = true ]; then
log_success "App bundle built successfully" log_success "App bundle built successfully"
fi fi
# Step 9: Auto-run app if requested # Step 10: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then if [ "$AUTO_RUN" = true ]; then
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9 safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
fi fi
# Step 10: Open Xcode if requested # Step 11: Open Xcode if requested
if [ "$OPEN_STUDIO" = true ]; then if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Xcode" "npx cap open ios" || exit 8 safe_execute "Opening Xcode" "npx cap open ios" || exit 8
fi fi

2
src/assets/styles/tailwind.css

@ -38,7 +38,7 @@
} }
.dialog { .dialog {
@apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[90%] overflow-y-auto; @apply bg-white px-4 py-6 rounded-lg w-full max-w-lg max-h-[calc(100vh-3rem)] overflow-y-auto;
} }
/* Markdown content styling to restore list elements */ /* Markdown content styling to restore list elements */

91
src/components/BulkMembersDialog.vue

@ -111,7 +111,7 @@
? 'bg-blue-600 text-white cursor-pointer' ? 'bg-blue-600 text-white cursor-pointer'
: 'bg-slate-400 text-slate-200 cursor-not-allowed', : 'bg-slate-400 text-slate-200 cursor-not-allowed',
]" ]"
@click="handleMainAction" @click="processSelectedMembers"
> >
{{ buttonText }} {{ buttonText }}
</button> </button>
@ -145,7 +145,7 @@ import { Contact } from "@/db/tables/contacts";
export default class BulkMembersDialog extends Vue { export default class BulkMembersDialog extends Vue {
@Prop({ default: "" }) activeDid!: string; @Prop({ default: "" }) activeDid!: string;
@Prop({ default: "" }) apiServer!: string; @Prop({ default: "" }) apiServer!: string;
@Prop({ required: true }) dialogType!: "admit" | "visibility"; // isOrganizer: true = organizer mode (admit members), false = member mode (set visibility)
@Prop({ required: true }) isOrganizer!: boolean; @Prop({ required: true }) isOrganizer!: boolean;
// Vue notification system // Vue notification system
@ -252,15 +252,7 @@ export default class BulkMembersDialog extends Vue {
return this.selectedMembers.includes(memberDid); return this.selectedMembers.includes(memberDid);
} }
async handleMainAction() { async processSelectedMembers() {
if (this.dialogType === "admit") {
await this.organizerAdmitAndAddWithVisibility();
} else {
await this.memberAddContactWithVisibility();
}
}
async organizerAdmitAndAddWithVisibility() {
try { try {
const selectedMembers: MemberData[] = this.membersData.filter((member) => const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did), this.selectedMembers.includes(member.did),
@ -275,16 +267,20 @@ export default class BulkMembersDialog extends Vue {
for (const member of selectedMembers) { for (const member of selectedMembers) {
try { try {
// First, admit the member // Organizer mode: admit and register the member first
if (this.isOrganizer) {
await this.admitMember(member); await this.admitMember(member);
// Register them
await this.registerMember(member); await this.registerMember(member);
admittedCount++; admittedCount++;
}
// If they're not a contact yet, add them as a contact // If they're not a contact yet, add them as a contact
if (!member.isContact) { if (!member.isContact) {
await this.addAsContact(member, true); // Organizer mode: set isRegistered to true, member mode: undefined
await this.addAsContact(
member,
this.isOrganizer ? true : undefined,
);
contactAddedCount++; contactAddedCount++;
} }
@ -299,6 +295,7 @@ export default class BulkMembersDialog extends Vue {
} }
// Show success notification // Show success notification
if (this.isOrganizer) {
if (admittedCount > 0) { if (admittedCount > 0) {
this.$notify( this.$notify(
{ {
@ -307,7 +304,7 @@ export default class BulkMembersDialog extends Vue {
title: "Members Admitted Successfully", title: "Members Admitted Successfully",
text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`, text: `${admittedCount} member${admittedCount === 1 ? "" : "s"} admitted and registered${contactAddedCount === 0 ? "" : admittedCount === contactAddedCount ? " and" : `, ${contactAddedCount}`}${contactAddedCount === 0 ? "" : ` added as contact${contactAddedCount === 1 ? "" : "s"}`}.`,
}, },
10000, 5000,
); );
} }
if (errors > 0) { if (errors > 0) {
@ -321,66 +318,28 @@ export default class BulkMembersDialog extends Vue {
5000, 5000,
); );
} }
} else {
this.close(notSelectedMembers.map((member) => member.did)); // Member mode: show contacts added notification
} catch (error) { if (contactAddedCount > 0) {
// eslint-disable-next-line no-console
console.error("Error admitting members:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Some errors occurred. Work with members individually below.",
},
5000,
);
}
}
async memberAddContactWithVisibility() {
try {
const selectedMembers: MemberData[] = this.membersData.filter((member) =>
this.selectedMembers.includes(member.did),
);
const notSelectedMembers: MemberData[] = this.membersData.filter(
(member) => !this.selectedMembers.includes(member.did),
);
let contactsAddedCount = 0;
for (const member of selectedMembers) {
try {
// If they're not a contact yet, add them as a contact first
if (!member.isContact) {
await this.addAsContact(member, undefined);
contactsAddedCount++;
}
// Set their seesMe to true
await this.updateContactVisibility(member.did, true);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Error processing member ${member.did}:`, error);
// Continue with other members even if one fails
}
}
// Show success notification
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "success", type: "success",
title: "Contacts Added Successfully", title: "Contacts Added Successfully",
text: `${contactsAddedCount} member${contactsAddedCount === 1 ? "" : "s"} added as contact${contactsAddedCount === 1 ? "" : "s"}.`, text: `${contactAddedCount} member${contactAddedCount === 1 ? "" : "s"} added as contact${contactAddedCount === 1 ? "" : "s"}.`,
}, },
5000, 5000,
); );
}
}
this.close(notSelectedMembers.map((member) => member.did)); this.close(notSelectedMembers.map((member) => member.did));
} catch (error) { } catch (error) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error("Error adding contacts:", error); console.error(
`Error ${this.isOrganizer ? "admitting members" : "adding contacts"}:`,
error,
);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -487,8 +446,8 @@ export default class BulkMembersDialog extends Vue {
} }
showContactInfo() { showContactInfo() {
const message = // isOrganizer: true = admit mode, false = visibility mode
this.dialogType === "admit" const message = this.isOrganizer
? "This user is already your contact, but they are not yet admitted to the meeting." ? "This user is already your contact, but they are not yet admitted to the meeting."
: "This user is already your contact, but your activities are not visible to them yet."; : "This user is already your contact, but your activities are not visible to them yet.";

398
src/components/EntityGrid.vue

@ -2,12 +2,55 @@
GiftedDialog.vue to provide a reusable grid layout * for displaying people, GiftedDialog.vue to provide a reusable grid layout * for displaying people,
projects, and special entities with selection. * * @author Matthew Raymer */ projects, and special entities with selection. * * @author Matthew Raymer */
<template> <template>
<ul :class="gridClasses"> <!-- Quick Search -->
<div id="QuickSearch" class="mb-4 flex items-center text-sm">
<input
v-model="searchTerm"
type="text"
placeholder="Search…"
class="block w-full rounded-l border border-r-0 border-slate-400 px-3 py-1.5 placeholder:italic placeholder:text-slate-400 focus:outline-none"
@input="handleSearchInput"
@keydown.enter="performSearch"
/>
<div
v-show="isSearching && searchTerm"
class="border-y border-slate-400 ps-2 py-1.5 text-center text-slate-400"
>
<font-awesome
icon="spinner"
class="fa-spin-pulse leading-[1.1]"
></font-awesome>
</div>
<button
:disabled="!searchTerm"
class="px-2 py-1.5 rounded-r bg-white border border-l-0 border-slate-400 text-slate-400 disabled:cursor-not-allowed"
@click="clearSearch"
>
<font-awesome
:icon="searchTerm ? 'times' : 'magnifying-glass'"
class="fa-fw"
></font-awesome>
</button>
</div>
<div
v-if="searchTerm && !isSearching && filteredEntities.length === 0"
class="mb-4 text-sm italic text-slate-500 text-center"
>
{{ searchTerm }} doesn't match any
{{ entityType === "people" ? "people" : "projects" }}. Try a different
search.
</div>
<ul
ref="scrollContainer"
class="border-t border-slate-300 mb-4 max-h-[60vh] overflow-y-auto"
>
<!-- Special entities (You, Unnamed) for people grids --> <!-- Special entities (You, Unnamed) for people grids -->
<template v-if="entityType === 'people'"> <template v-if="entityType === 'people'">
<!-- "You" entity --> <!-- "You" entity -->
<SpecialEntityCard <SpecialEntityCard
v-if="showYouEntity" v-if="showYouEntity && !searchTerm.trim()"
entity-type="you" entity-type="you"
label="You" label="You"
icon="hand" icon="hand"
@ -21,6 +64,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- "Unnamed" entity --> <!-- "Unnamed" entity -->
<SpecialEntityCard <SpecialEntityCard
v-if="showUnnamedEntity && !searchTerm.trim()"
entity-type="unnamed" entity-type="unnamed"
:label="unnamedEntityName" :label="unnamedEntityName"
icon="circle-question" icon="circle-question"
@ -38,6 +82,49 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<!-- Entity cards (people or projects) --> <!-- Entity cards (people or projects) -->
<template v-if="entityType === 'people'"> <template v-if="entityType === 'people'">
<!-- When showing contacts without search: split into recent and alphabetical -->
<template v-if="!searchTerm.trim()">
<!-- Recently Added Section -->
<template v-if="recentContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Added
</li>
<PersonCard
v-for="person in recentContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
<!-- Alphabetical Section -->
<template v-if="alphabeticalContacts.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Everyone Else
</li>
<PersonCard
v-for="person in alphabeticalContacts"
:key="person.did"
:person="person"
:conflicted="isPersonConflicted(person.did)"
:show-time-icon="true"
:notify="notify"
:conflict-context="conflictContext"
@person-selected="handlePersonSelected"
/>
</template>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<PersonCard <PersonCard
v-for="person in displayedEntities as Contact[]" v-for="person in displayedEntities as Contact[]"
:key="person.did" :key="person.did"
@ -49,6 +136,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
@person-selected="handlePersonSelected" @person-selected="handlePersonSelected"
/> />
</template> </template>
</template>
<template v-else-if="entityType === 'projects'"> <template v-else-if="entityType === 'projects'">
<ProjectCard <ProjectCard
@ -63,28 +151,27 @@ projects, and special entities with selection. * * @author Matthew Raymer */
@project-selected="handleProjectSelected" @project-selected="handleProjectSelected"
/> />
</template> </template>
<!-- Show All navigation -->
<ShowAllCard
v-if="shouldShowAll"
:entity-type="entityType"
:route-name="showAllRoute"
:query-params="showAllQueryParams"
/>
</ul> </ul>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator"; import { Component, Prop, Vue, Emit, Watch } from "vue-facing-decorator";
import { useInfiniteScroll } from "@vueuse/core";
import PersonCard from "./PersonCard.vue"; import PersonCard from "./PersonCard.vue";
import ProjectCard from "./ProjectCard.vue"; import ProjectCard from "./ProjectCard.vue";
import SpecialEntityCard from "./SpecialEntityCard.vue"; import SpecialEntityCard from "./SpecialEntityCard.vue";
import ShowAllCard from "./ShowAllCard.vue";
import { Contact } from "../db/tables/contacts"; 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";
/**
* Constants for infinite scroll configuration
*/
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
/** /**
* EntityGrid - Unified grid layout for displaying people or projects * EntityGrid - Unified grid layout for displaying people or projects
* *
@ -93,7 +180,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
* - Special entity integration (You, Unnamed) * - Special entity integration (You, Unnamed)
* - Conflict detection integration * - Conflict detection integration
* - Empty state messaging * - Empty state messaging
* - Show All navigation
* - Event delegation for entity selection * - Event delegation for entity selection
* - Warning notifications for conflicted entities * - Warning notifications for conflicted entities
* - Template streamlined with computed CSS properties * - Template streamlined with computed CSS properties
@ -104,7 +190,6 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
PersonCard, PersonCard,
ProjectCard, ProjectCard,
SpecialEntityCard, SpecialEntityCard,
ShowAllCard,
}, },
}) })
export default class EntityGrid extends Vue { export default class EntityGrid extends Vue {
@ -112,14 +197,21 @@ export default class EntityGrid extends Vue {
@Prop({ required: true }) @Prop({ required: true })
entityType!: "people" | "projects"; entityType!: "people" | "projects";
// Search state
searchTerm = "";
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
/** Array of entities to display */ /** Array of entities to display */
@Prop({ required: true }) @Prop({ required: true })
entities!: Contact[] | PlanData[]; entities!: Contact[] | PlanData[];
/** Maximum number of entities to display */
@Prop({ default: 10 })
maxItems!: number;
/** Active user's DID */ /** Active user's DID */
@Prop({ required: true }) @Prop({ required: true })
activeDid!: string; activeDid!: string;
@ -140,18 +232,14 @@ export default class EntityGrid extends Vue {
@Prop({ default: true }) @Prop({ default: true })
showYouEntity!: boolean; showYouEntity!: boolean;
/** Whether to show the "Unnamed" entity for people grids */
@Prop({ default: true })
showUnnamedEntity!: boolean;
/** Whether the "You" entity is selectable */ /** Whether the "You" entity is selectable */
@Prop({ default: true }) @Prop({ default: true })
youSelectable!: boolean; youSelectable!: boolean;
/** Route name for "Show All" navigation */
@Prop({ default: "" })
showAllRoute!: string;
/** Query parameters for "Show All" navigation */
@Prop({ default: () => ({}) })
showAllQueryParams!: Record<string, string>;
/** Notification function from parent component */ /** Notification function from parent component */
@Prop() @Prop()
notify?: (notification: NotificationIface, timeout?: number) => void; notify?: (notification: NotificationIface, timeout?: number) => void;
@ -160,42 +248,31 @@ export default class EntityGrid extends Vue {
@Prop({ default: "other party" }) @Prop({ default: "other party" })
conflictContext!: string; conflictContext!: string;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/** /**
* Function to determine which entities to display (allows parent control) * Function to determine which entities to display (allows parent control)
* *
* This function prop allows parent components to customize which entities * This function prop allows parent components to customize which entities
* are displayed in the grid, enabling advanced filtering, sorting, and * are displayed in the grid, enabling advanced filtering and sorting.
* display logic beyond the default simple slice behavior. * Note: Infinite scroll is disabled when this prop is provided.
* *
* @param entities - The full array of entities (Contact[] or PlanData[]) * @param entities - The full array of entities (Contact[] or PlanData[])
* @param entityType - The type of entities being displayed ("people" or "projects") * @param entityType - The type of entities being displayed ("people" or "projects")
* @param maxItems - The maximum number of items to display (from maxItems prop)
* @returns Filtered/sorted array of entities to display * @returns Filtered/sorted array of entities to display
* *
* @example * @example
* // Custom filtering: only show contacts with profile images * // Custom filtering: only show contacts with profile images
* :display-entities-function="(entities, type, max) => * :display-entities-function="(entities, type) =>
* entities.filter(e => e.profileImageUrl).slice(0, max)" * entities.filter(e => e.profileImageUrl)"
* *
* @example * @example
* // Custom sorting: sort projects by name * // Custom sorting: sort projects by name
* :display-entities-function="(entities, type, max) => * :display-entities-function="(entities, type) =>
* entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, max)" * entities.sort((a, b) => a.name.localeCompare(b.name))"
*
* @example
* // Advanced logic: different limits for different entity types
* :display-entities-function="(entities, type, max) =>
* type === 'projects' ? entities.slice(0, 5) : entities.slice(0, max)"
*/ */
@Prop({ default: null }) @Prop({ default: null })
displayEntitiesFunction?: ( displayEntitiesFunction?: (
entities: Contact[] | PlanData[], entities: Contact[] | PlanData[],
entityType: "people" | "projects", entityType: "people" | "projects",
maxItems: number,
) => Contact[] | PlanData[]; ) => Contact[] | PlanData[];
/** /**
@ -206,33 +283,60 @@ export default class EntityGrid extends Vue {
} }
/** /**
* Computed CSS classes for the grid layout * Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied
*/ */
get gridClasses(): string { get displayedEntities(): Contact[] | PlanData[] {
const baseClasses = "grid gap-x-2 gap-y-4 text-center mb-4"; // If searching, return filtered results with infinite scroll
if (this.searchTerm.trim()) {
return this.filteredEntities.slice(0, this.displayedCount);
}
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entities, this.entityType);
}
// Default: projects use infinite scroll
if (this.entityType === "projects") { if (this.entityType === "projects") {
return `${baseClasses} grid-cols-3 md:grid-cols-4`; return (this.entities as PlanData[]).slice(0, this.displayedCount);
} else {
return `${baseClasses} grid-cols-4 sm:grid-cols-5 md:grid-cols-6`;
} }
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
return [];
} }
/** /**
* Computed entities to display - uses function prop if provided, otherwise defaults * Get the 3 most recently added contacts (when showing contacts and not searching)
*/ */
get displayedEntities(): Contact[] | PlanData[] { get recentContacts(): Contact[] {
if (this.displayEntitiesFunction) { if (this.entityType !== "people" || this.searchTerm.trim()) {
return this.displayEntitiesFunction( return [];
this.entities, }
this.entityType, // Entities are already sorted by date added (newest first)
this.maxItems, return (this.entities as Contact[]).slice(0, 3);
);
} }
// Default implementation for backward compatibility /**
const maxDisplay = this.entityType === "projects" ? 7 : this.maxItems; * Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
return this.entities.slice(0, maxDisplay); * Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
return [];
}
// Skip the first 3 (recent contacts) and sort the rest alphabetically
// Create a copy to avoid mutating the original array
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Apply infinite scroll: show based on displayedCount (minus the 3 recent)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
} }
/** /**
@ -246,15 +350,6 @@ export default class EntityGrid extends Vue {
} }
} }
/**
* Whether to show the "Show All" navigation
*/
get shouldShowAll(): boolean {
return (
!this.hideShowAll && this.entities.length > 0 && this.showAllRoute !== ""
);
}
/** /**
* Whether the "You" entity is conflicted * Whether the "You" entity is conflicted
*/ */
@ -328,6 +423,144 @@ export default class EntityGrid extends Vue {
}); });
} }
/**
* Handle search input with debouncing
*/
handleSearchInput(): void {
// Show spinner immediately when user types
this.isSearching = true;
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set new timeout for 500ms delay
this.searchTimeout = setTimeout(() => {
this.performSearch();
}, 500);
}
/**
* Perform the actual search
*/
async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) {
this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
return;
}
this.isSearching = true;
try {
// Simulate async search (in case we need to add API calls later)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
if (this.entityType === "people") {
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
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
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
} finally {
this.isSearching = false;
}
}
/**
* Clear the search
*/
clearSearch(): void {
this.searchTerm = "";
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// Clear any pending timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
}
/**
* Determine if more entities can be loaded
*/
canLoadMore(): boolean {
if (this.displayEntitiesFunction) {
// Custom function disables infinite scroll
return false;
}
if (this.searchTerm.trim()) {
// Search mode: check filtered entities
return this.displayedCount < this.filteredEntities.length;
}
if (this.entityType === "projects") {
// Projects: check if more available
return this.displayedCount < this.entities.length;
}
// People: check if more alphabetical contacts available
// Total available = 3 recent + all alphabetical
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
return this.displayedCount < totalAvailable;
}
/**
* Initialize infinite scroll on mount
*/
mounted(): void {
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
() => {
// Load more: increment displayedCount
this.displayedCount += INCREMENT_SIZE;
},
{
distance: 50, // pixels from bottom
canLoadMore: () => this.canLoadMore(),
},
);
this.infiniteScrollReset = reset;
}
});
}
// Emit methods using @Emit decorator // Emit methods using @Emit decorator
@Emit("entity-selected") @Emit("entity-selected")
@ -340,6 +573,33 @@ export default class EntityGrid extends Vue {
} { } {
return data; return data;
} }
/**
* Watch for changes in search term to reset displayed count
*/
@Watch("searchTerm")
onSearchTermChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Watch for changes in entities prop to reset displayed count
*/
@Watch("entities")
onEntitiesChange(): void {
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
}
/**
* Cleanup timeouts when component is destroyed
*/
beforeUnmount(): void {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
}
} }
</script> </script>

69
src/components/EntitySelectionStep.vue

@ -3,10 +3,9 @@ from GiftedDialog.vue to handle the complete step 1 * entity selection interface
with dynamic labeling and grid display. * * Features: * - Dynamic step labeling with dynamic labeling and grid display. * * Features: * - Dynamic step labeling
based on context * - EntityGrid integration for unified entity display * - based on context * - EntityGrid integration for unified entity display * -
Conflict detection and prevention * - Special entity handling (You, Unnamed) * - Conflict detection and prevention * - Special entity handling (You, Unnamed) * -
Show All navigation with context preservation * - Cancel functionality * - Event Cancel functionality * - Event delegation for entity selection * - Warning
delegation for entity selection * - Warning notifications for conflicted notifications for conflicted entities * - Template streamlined with computed CSS
entities * - Template streamlined with computed CSS properties * * @author properties * * @author Matthew Raymer */
Matthew Raymer */
<template> <template>
<div id="sectionGiftedGiver"> <div id="sectionGiftedGiver">
<label class="block font-bold mb-4"> <label class="block font-bold mb-4">
@ -16,18 +15,14 @@ Matthew Raymer */
<EntityGrid <EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'" :entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts" :entities="shouldShowProjects ? projects : allContacts"
:max-items="10"
:active-did="activeDid" :active-did="activeDid"
:all-my-dids="allMyDids" :all-my-dids="allMyDids"
:all-contacts="allContacts" :all-contacts="allContacts"
:conflict-checker="conflictChecker" :conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity" :show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable" :you-selectable="youSelectable"
:show-all-route="showAllRoute"
:show-all-query-params="showAllQueryParams"
:notify="notify" :notify="notify"
:conflict-context="conflictContext" :conflict-context="conflictContext"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected" @entity-selected="handleEntitySelected"
/> />
@ -68,7 +63,6 @@ interface EntitySelectionEvent {
* - EntityGrid integration for unified entity display * - EntityGrid integration for unified entity display
* - Conflict detection and prevention * - Conflict detection and prevention
* - Special entity handling (You, Unnamed) * - Special entity handling (You, Unnamed)
* - Show All navigation with context preservation
* - Cancel functionality * - Cancel functionality
* - Event delegation for entity selection * - Event delegation for entity selection
* - Warning notifications for conflicted entities * - Warning notifications for conflicted entities
@ -154,10 +148,6 @@ export default class EntitySelectionStep extends Vue {
@Prop() @Prop()
notify?: (notification: NotificationIface, timeout?: number) => void; notify?: (notification: NotificationIface, timeout?: number) => void;
/** Whether to hide the "Show All" navigation */
@Prop({ default: false })
hideShowAll!: boolean;
/** /**
* CSS classes for the cancel button * CSS classes for the cancel button
*/ */
@ -222,59 +212,6 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid); return !this.conflictChecker(this.activeDid);
} }
/**
* Route name for "Show All" navigation
*/
get showAllRoute(): string {
if (this.shouldShowProjects) {
return "discover";
} else if (this.allContacts.length > 0) {
return "contact-gift";
}
return "";
}
/**
* Query parameters for "Show All" navigation
*/
get showAllQueryParams(): Record<string, string> {
const baseParams = {
stepType: this.stepType,
giverEntityType: this.giverEntityType,
recipientEntityType: this.recipientEntityType,
// Form field values to preserve
description: this.description,
amountInput: this.amountInput,
unitCode: this.unitCode,
offerId: this.offerId,
fromProjectId: this.fromProjectId,
toProjectId: this.toProjectId,
showProjects: this.showProjects.toString(),
isFromProjectView: this.isFromProjectView.toString(),
};
if (this.shouldShowProjects) {
// For project contexts, still pass entity type information
return baseParams;
}
return {
...baseParams,
// Always pass both giver and recipient info for context preservation
giverProjectId: this.fromProjectId || "",
giverProjectName: this.giver?.name || "",
giverProjectImage: this.giver?.image || "",
giverProjectHandleId: this.giver?.handleId || "",
giverDid: this.giverEntityType === "person" ? this.giver?.did || "" : "",
recipientProjectId: this.toProjectId || "",
recipientProjectName: this.receiver?.name || "",
recipientProjectImage: this.receiver?.image || "",
recipientProjectHandleId: this.receiver?.handleId || "",
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did || "" : "",
};
}
/** /**
* Handle entity selection from EntityGrid * Handle entity selection from EntityGrid
*/ */

4
src/components/GiftedDialog.vue

@ -29,7 +29,6 @@
:unit-code="unitCode" :unit-code="unitCode"
:offer-id="offerId" :offer-id="offerId"
:notify="$notify" :notify="$notify"
:hide-show-all="hideShowAll"
@entity-selected="handleEntitySelected" @entity-selected="handleEntitySelected"
@cancel="cancel" @cancel="cancel"
/> />
@ -117,7 +116,6 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = ""; @Prop() fromProjectId = "";
@Prop() toProjectId = ""; @Prop() toProjectId = "";
@Prop() isFromProjectView = false; @Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person" as @Prop({ default: "person" }) giverEntityType = "person" as
| "person" | "person"
| "project"; | "project";
@ -233,7 +231,7 @@ export default class GiftedDialog extends Vue {
apiServer: this.apiServer, apiServer: this.apiServer,
}); });
this.allContacts = await this.$contacts(); this.allContacts = await this.$contactsByDateAdded();
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();

1
src/components/MembersList.vue

@ -223,7 +223,6 @@
ref="bulkMembersDialog" ref="bulkMembersDialog"
:active-did="activeDid" :active-did="activeDid"
:api-server="apiServer" :api-server="apiServer"
:dialog-type="isOrganizer ? 'admit' : 'visibility'"
:is-organizer="isOrganizer" :is-organizer="isOrganizer"
@close="closeBulkMembersDialogCallback" @close="closeBulkMembersDialogCallback"
/> />

34
src/components/PersonCard.vue

@ -3,30 +3,25 @@ GiftedDialog.vue to handle person entity display * with selection states and
conflict detection. * * @author Matthew Raymer */ conflict detection. * * @author Matthew Raymer */
<template> <template>
<li :class="cardClasses" @click="handleClick"> <li :class="cardClasses" @click="handleClick">
<div class="relative w-fit mx-auto"> <div>
<EntityIcon <EntityIcon
v-if="person.did" v-if="person.did"
:contact="person" :contact="person"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1" class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/> />
<font-awesome <font-awesome
v-else v-else
icon="circle-question" icon="circle-question"
class="text-slate-400 text-5xl mb-1" class="text-slate-400 text-5xl mb-1 shrink-0"
/> />
<!-- Time icon overlay for contacts -->
<div
v-if="person.did && showTimeIcon"
class="rounded-full bg-slate-400 absolute bottom-0 right-0 p-1 translate-x-1/3"
>
<font-awesome icon="clock" class="block text-white text-xs w-[1em]" />
</div>
</div> </div>
<div class="overflow-hidden">
<h3 :class="nameClasses"> <h3 :class="nameClasses">
{{ displayName }} {{ displayName }}
</h3> </h3>
<p class="text-xs text-slate-500 truncate">{{ person.did }}</p>
</div>
</li> </li>
</template> </template>
@ -81,29 +76,32 @@ export default class PersonCard extends Vue {
* Computed CSS classes for the card * Computed CSS classes for the card
*/ */
get cardClasses(): string { get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) { if (!this.selectable || this.conflicted) {
return "opacity-50 cursor-not-allowed"; return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
} }
return "cursor-pointer hover:bg-slate-50";
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
} }
/** /**
* Computed CSS classes for the person name * Computed CSS classes for the person name
*/ */
get nameClasses(): string { get nameClasses(): string {
const baseClasses = const baseNameClasses = "text-sm font-semibold truncate";
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) { if (this.conflicted) {
return `${baseClasses} text-slate-400`; return `${baseNameClasses} text-slate-500`;
} }
// Add italic styling for entities without set names // Add italic styling for entities without set names
if (!this.person.name) { if (!this.person.name) {
return `${baseClasses} italic text-slate-500`; return `${baseNameClasses} italic text-slate-500`;
} }
return baseClasses; return baseNameClasses;
} }
/** /**

17
src/components/ProjectCard.vue

@ -2,26 +2,27 @@
GiftedDialog.vue to handle project entity display * with selection states and GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */ issuer information. * * @author Matthew Raymer */
<template> <template>
<li class="cursor-pointer" @click="handleClick"> <li
<div class="relative w-fit mx-auto"> class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
@click="handleClick"
>
<ProjectIcon <ProjectIcon
:entity-id="project.handleId" :entity-id="project.handleId"
:icon-size="48" :icon-size="48"
:image-url="project.image" :image-url="project.image"
class="!size-[3rem] mx-auto border border-slate-300 bg-white overflow-hidden rounded-full mb-1" class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/> />
</div>
<h3 <div class="overflow-hidden">
class="text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden" <h3 class="text-sm font-semibold truncate">
>
{{ project.name || unnamedProject }} {{ project.name || unnamedProject }}
</h3> </h3>
<div class="text-xs text-slate-500 truncate"> <div class="text-xs text-slate-500 truncate">
<font-awesome icon="user" class="fa-fw text-slate-400" /> <font-awesome icon="user" class="text-slate-400" />
{{ issuerDisplayName }} {{ issuerDisplayName }}
</div> </div>
</div>
</li> </li>
</template> </template>

66
src/components/ShowAllCard.vue

@ -1,66 +0,0 @@
/** * ShowAllCard.vue - Show All navigation card component * * Extracted from
GiftedDialog.vue to handle "Show All" navigation * for both people and projects
entity types. * * @author Matthew Raymer */
<template>
<li class="cursor-pointer">
<router-link :to="navigationRoute" class="block text-center">
<font-awesome icon="circle-right" class="text-blue-500 text-5xl mb-1" />
<h3
class="text-xs text-slate-500 font-medium italic text-ellipsis whitespace-nowrap overflow-hidden"
>
Show All
</h3>
</router-link>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { RouteLocationRaw } from "vue-router";
/**
* ShowAllCard - Displays "Show All" navigation for entity grids
*
* Features:
* - Provides navigation to full entity listings
* - Supports different routes based on entity type
* - Maintains context through query parameters
* - Consistent visual styling with other cards
*/
@Component({ name: "ShowAllCard" })
export default class ShowAllCard extends Vue {
/** Type of entities being shown */
@Prop({ required: true })
entityType!: "people" | "projects";
/** Route name to navigate to */
@Prop({ required: true })
routeName!: string;
/** Query parameters to pass to the route */
@Prop({ default: () => ({}) })
queryParams!: Record<string, string>;
/**
* Computed navigation route with query parameters
*/
get navigationRoute(): RouteLocationRaw {
return {
name: this.routeName,
query: this.queryParams,
};
}
}
</script>
<style scoped>
/* Ensure router-link styling is consistent */
a {
text-decoration: none;
}
a:hover .fa-circle-right {
transform: scale(1.1);
transition: transform 0.2s ease;
}
</style>

13
src/components/SpecialEntityCard.vue

@ -63,23 +63,24 @@ export default class SpecialEntityCard extends Vue {
conflictContext!: string; conflictContext!: string;
/** /**
* Computed CSS classes for the card container * Computed CSS classes for the card
*/ */
get cardClasses(): string { get cardClasses(): string {
const baseClasses = "block"; const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (!this.selectable || this.conflicted) { if (!this.selectable || this.conflicted) {
return `${baseClasses} cursor-not-allowed opacity-50`; return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
} }
return `${baseClasses} cursor-pointer`; return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
} }
/** /**
* Computed CSS classes for the icon * Computed CSS classes for the icon
*/ */
get iconClasses(): string { get iconClasses(): string {
const baseClasses = "text-5xl mb-1"; const baseClasses = "text-[2rem]";
if (this.conflicted) { if (this.conflicted) {
return `${baseClasses} text-slate-400`; return `${baseClasses} text-slate-400`;
@ -101,7 +102,7 @@ export default class SpecialEntityCard extends Vue {
*/ */
get nameClasses(): string { get nameClasses(): string {
const baseClasses = const baseClasses =
"text-xs font-medium text-ellipsis whitespace-nowrap overflow-hidden"; "text-sm font-semibold text-ellipsis whitespace-nowrap overflow-hidden";
if (this.conflicted) { if (this.conflicted) {
return `${baseClasses} text-slate-400`; return `${baseClasses} text-slate-400`;

5
src/libs/endorserServer.ts

@ -1686,7 +1686,10 @@ export async function register(
"Registration thrown error:", "Registration thrown error:",
errorMessage || JSON.stringify(err), errorMessage || JSON.stringify(err),
); );
return { error: errorMessage || "Got a server error when registering." }; return {
error:
(errorMessage as string) || "Got a server error when registering.",
};
} }
return { error: "Got a server error when registering." }; return { error: "Got a server error when registering." };
} }

78
src/services/platforms/CapacitorPlatformService.ts

@ -96,7 +96,61 @@ export class CapacitorPlatformService
} }
try { try {
// Create/Open database // Try to create/Open database connection
try {
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
} catch (createError: unknown) {
// If connection already exists, try to retrieve it or handle gracefully
const errorMessage =
createError instanceof Error
? createError.message
: String(createError);
const errorObj =
typeof createError === "object" && createError !== null
? (createError as { errorMessage?: string; message?: string })
: {};
const fullErrorMessage =
errorObj.errorMessage || errorObj.message || errorMessage;
if (fullErrorMessage.includes("already exists")) {
logger.debug(
"[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve",
);
// Check if connection exists in JavaScript Map
const isConnResult = await this.sqlite.isConnection(
this.dbName,
false,
);
if (isConnResult.result) {
// Connection exists in Map, retrieve it
this.db = await this.sqlite.retrieveConnection(this.dbName, false);
logger.debug(
"[CapacitorPlatformService] Successfully retrieved existing connection from Map",
);
} else {
// Connection exists on native side but not in JavaScript Map
// This can happen when the app is restarted but native connections persist
// Try to close the native connection first, then create a new one
logger.debug(
"[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating",
);
try {
await this.sqlite.closeConnection(this.dbName, false);
} catch (closeError) {
// Ignore close errors - connection might not be properly tracked
logger.debug(
"[CapacitorPlatformService] Error closing connection (may be expected):",
closeError,
);
}
// Now try to create the connection again
this.db = await this.sqlite.createConnection( this.db = await this.sqlite.createConnection(
this.dbName, this.dbName,
false, false,
@ -104,8 +158,30 @@ export class CapacitorPlatformService
1, 1,
false, false,
); );
logger.debug(
"[CapacitorPlatformService] Successfully created connection after cleanup",
);
}
} else {
// Re-throw if it's a different error
throw createError;
}
}
// Open the connection if it's not already open
try {
await this.db.open(); await this.db.open();
} catch (openError: unknown) {
const openErrorMessage =
openError instanceof Error ? openError.message : String(openError);
// If already open, that's fine - continue
if (!openErrorMessage.includes("already open")) {
throw openError;
}
logger.debug(
"[CapacitorPlatformService] Database connection already open",
);
}
// Set journal mode to WAL for better performance // Set journal mode to WAL for better performance
// await this.db.execute("PRAGMA journal_mode=WAL;"); // await this.db.execute("PRAGMA journal_mode=WAL;");

16
src/test/EntityGridFunctionPropTest.vue

@ -19,7 +19,6 @@
<EntityGrid <EntityGrid
entity-type="people" entity-type="people"
:entities="people" :entities="people"
:max-items="5"
:active-did="activeDid" :active-did="activeDid"
:all-my-dids="allMyDids" :all-my-dids="allMyDids"
:all-contacts="people" :all-contacts="people"
@ -39,7 +38,6 @@
<EntityGrid <EntityGrid
entity-type="projects" entity-type="projects"
:entities="projects" :entities="projects"
:max-items="3"
:active-did="activeDid" :active-did="activeDid"
:all-my-dids="allMyDids" :all-my-dids="allMyDids"
:all-contacts="people" :all-contacts="people"
@ -152,11 +150,8 @@ export default class EntityGridFunctionPropTest extends Vue {
customPeopleFunction = ( customPeopleFunction = (
entities: Contact[], entities: Contact[],
_entityType: string, _entityType: string,
maxItems: number,
): Contact[] => { ): Contact[] => {
return entities return entities.filter((person) => person.profileImageUrl);
.filter((person) => person.profileImageUrl)
.slice(0, maxItems);
}; };
/** /**
@ -165,7 +160,6 @@ export default class EntityGridFunctionPropTest extends Vue {
customProjectsFunction = ( customProjectsFunction = (
entities: PlanData[], entities: PlanData[],
_entityType: string, _entityType: string,
_maxItems: number,
): PlanData[] => { ): PlanData[] => {
return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3); return entities.sort((a, b) => a.name.localeCompare(b.name)).slice(0, 3);
}; };
@ -200,16 +194,16 @@ export default class EntityGridFunctionPropTest extends Vue {
*/ */
get displayedPeopleCount(): number { get displayedPeopleCount(): number {
if (this.useCustomFunction) { if (this.useCustomFunction) {
return this.customPeopleFunction(this.people, "people", 5).length; return this.customPeopleFunction(this.people, "people").length;
} }
return Math.min(5, this.people.length); return Math.min(10, this.people.length); // Initial batch size for infinite scroll
} }
get displayedProjectsCount(): number { get displayedProjectsCount(): number {
if (this.useCustomFunction) { if (this.useCustomFunction) {
return this.customProjectsFunction(this.projects, "projects", 3).length; return this.customProjectsFunction(this.projects, "projects").length;
} }
return Math.min(7, this.projects.length); return Math.min(10, this.projects.length); // Initial batch size for infinite scroll
} }
} }
</script> </script>

15
src/utils/PlatformServiceMixin.ts

@ -970,6 +970,20 @@ export const PlatformServiceMixin = {
return this.$normalizeContacts(rawContacts); return this.$normalizeContacts(rawContacts);
}, },
/**
* Load all contacts sorted by when they were added (by ID)
* Always fetches fresh data from database for consistency
* Handles JSON string/object duality for contactMethods field
* @returns Promise<Contact[]> Array of normalized contact objects sorted by addition date (newest first)
*/
async $contactsByDateAdded(): Promise<Contact[]> {
const rawContacts = (await this.$query(
"SELECT * FROM contacts ORDER BY id DESC",
)) as ContactMaybeWithJsonStrings[];
return this.$normalizeContacts(rawContacts);
},
/** /**
* Ultra-concise shortcut for getting number of contacts * Ultra-concise shortcut for getting number of contacts
* @returns Promise<number> Total number of contacts * @returns Promise<number> Total number of contacts
@ -2057,6 +2071,7 @@ declare module "@vue/runtime-core" {
// Specialized shortcuts - contacts cached, settings fresh // Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>; $contacts(): Promise<Contact[]>;
$contactsByDateAdded(): Promise<Contact[]>;
$contactCount(): Promise<number>; $contactCount(): Promise<number>;
$settings(defaults?: Settings): Promise<Settings>; $settings(defaults?: Settings): Promise<Settings>;
$accountSettings(did?: string, defaults?: Settings): Promise<Settings>; $accountSettings(did?: string, defaults?: Settings): Promise<Settings>;

Loading…
Cancel
Save