Compare commits

..

18 Commits

Author SHA1 Message Date
Jose Olarte III
f71c76fcd3 Fix: removed links and elements
- Removed links from icons and giver name
- Removed confirm button
- Lint fixes
2025-03-21 15:52:45 +08:00
Matthew Raymer
d024db2258 feature: move amount and wire it up 2025-03-21 07:03:31 +00:00
Jose Olarte III
b40604f8a6 Amount above arrow
- Needs wiring up
2025-03-19 20:47:10 +08:00
Jose Olarte III
436f40813c Source-destination compacted
- Narrower max-width
- Element sizes adjusted
- Switched to a more controllable unit for widths and heights
2025-03-17 21:03:58 +08:00
Jose Olarte III
77b296b606 Identicon responsive size fix + lint-fix 2025-03-17 17:59:59 +08:00
Matthew Raymer
8f7d794962 fix: improve image handling and icon support
- Fix image load event handler signature
- Add alt text for accessibility
- Add building icon to FontAwesome library

Technical Changes:
- Update cacheImage event to pass only image URL
- Add proper alt text for activity images
- Add faBuilding icon to FontAwesome library

This improves image handling and accessibility while adding
needed icon support for the activity feed interface.
2025-03-07 12:58:14 +00:00
Matthew Raymer
fa7d6317b9 feat: add claim confirmation functionality to activity feed
- Add confirm button functionality to ActivityListItem
- Implement confirmation logic in HomeView
- Add proper button state handling and validation

Technical Changes:
- Add canConfirm computed property to validate confirmation ability
- Add handleConfirmClick method with proper error handling
- Pass required props (isRegistered, activeDid, confirmerIdList)
- Add confirmation dialog with user verification
- Implement claim submission with proper cleanup
- Add visual feedback for button states
- Update feed after successful confirmation

UI/UX Improvements:
- Add disabled state styling for confirm button
- Show proper error messages for invalid confirmation attempts
- Add loading and success notifications
- Improve button accessibility with proper states

Bug Fixes:
- Make apiServer optional in settings type
- Fix settings update during registration
- Add proper type checking for claim confirmation

This adds the ability to confirm claims directly from the
activity feed with proper validation, error handling, and
user feedback. The confirmation flow matches the existing
claim view confirmation functionality.
2025-03-07 10:22:53 +00:00
Jose Olarte III
4a75cdf20e Homeview changes
- Moved activity image further up the frame
- Added placeholder icon for projects
- Minor fixes
2025-03-04 20:38:14 +08:00
Jose Olarte III
79fdb9e570 Homeview design adjustments
- Added markup for poster info (needs wiring)
- Repositioned giver and receiver type icons to beside their labels
- Added "Confirm" button to bottom right of item card (timestamp repositioned to poster info as a result)
- Spacing tweaks
2025-03-03 19:34:09 +08:00
Jose Olarte III
aa09827317 Type fixes 2025-02-28 21:00:03 +08:00
Matthew Raymer
cc1780bd01 refactor: extract ActivityListItem into separate component
- Move activity list item markup from HomeView to new component
- Improve code organization and reusability
- Pass required props for claim handling and image viewing
- Maintain existing functionality while reducing component complexity
- Clean up unused commented code in HomeView

This refactor improves code maintainability by extracting the activity
feed item logic into its own component.
2025-02-28 09:34:59 +00:00
Jose Olarte III
e5d9c25ad4 Comments 2025-02-27 21:21:01 +08:00
Jose Olarte III
ef8c2e6093 In-progress: homeview design refresh
I had to comment out line 544 because it was causing errors (and seemed redundant?)
2025-02-27 21:12:29 +08:00
61afba3bca show totals of gives to a project 2025-02-22 20:34:23 -07:00
eabe2b9448 fix problem when going directly to people-map where the search results disappear 2025-02-17 20:35:05 -07:00
5eaaf32043 bump version and add "-beta" (was 0.4.3 now 0.4.4-beta) 2025-02-17 19:22:03 -07:00
1e9c3f3101 add Discover query param searchPeople to go straight to people map 2025-02-17 19:19:38 -07:00
2e60e2bba9 bump version and add "-beta" 2025-02-17 08:55:07 -07:00
11 changed files with 703 additions and 224 deletions

View File

@@ -259,13 +259,23 @@ See `.env.*` files for configuration.
## Deployment
### Version Management
1. Update CHANGELOG.md with new changes
2. Update version in package.json
3. Commit changes and tag release:
```bash
git tag <VERSION_TAG>
git push origin <VERSION_TAG>
```
4. After deployment, update package.json with next version + "-beta"
### Test Server
```bash
# Build using staging environment
npm run build -- --mode staging
# Deploy to test server
rsync -azvu -e "ssh -i ~/.ssh/your_key" dist ubuntutest@test.timesafari.app:time-safari/
rsync -azvu -e "ssh -i ~/.ssh/<YOUR_KEY>" dist ubuntutest@test.timesafari.app:time-safari/
```
### Production Server
@@ -274,26 +284,15 @@ rsync -azvu -e "ssh -i ~/.ssh/your_key" dist ubuntutest@test.timesafari.app:time
pkgx +npm sh
cd crowd-funder-for-time-pwa
git checkout master && git pull
git checkout <version_tag>
git checkout <VERSION_TAG>
npm install
npm run build
cd -
# Backup and deploy
mv time-safari/dist time-safari-dist-prev.0
mv crowd-funder-for-time-pwa/dist time-safari/
mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/
```
### Version Management
1. Update CHANGELOG.md with new changes
2. Update version in package.json
3. Commit changes and tag release:
```bash
git tag <version>
git push origin <version>
```
4. After deployment, update package.json with next version + "-beta"
## Troubleshooting
### Common Build Issues

View File

@@ -6,12 +6,30 @@ 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).
== [0.4.2] - 2025.02.17
### Merged
- Master to split_process_build
- fixed path issues
- all tests passing
- capacitor build to Android working
## [0.4.5] - 2025.02.23
### Added
- Total amounts of gives on project page
### Changed in DB or environment
- Requires Endorser.ch version 4.2.6+
## [0.4.4] - 2025.02.17
### Fixed
- On production (due to data?) the search results would disappear after scrolling down. Now we don't show any results when going to the people map with a shortcut.
## [0.4.3] - 2025.02.17
### Added
- Discover query parameter searchPeople to go directly to the people map
## [0.4.2] - 2025.02.17
### Added
- Capacitor build to Android
### Fixed
- Path issues
## [0.4.1] - 2025.02.16
### Fixed
@@ -23,7 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Images in the home feed now take up the full width of the card.
- Clicking the image previously, would open the image in a new tab. Now, clicking the image opens the image in a lightbox view.
### Added
- Clicking an image also now displays an in-app lightbox view of the image.
- The lightbox view includes a download button for the image in mobile view.

8
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "TimeSafari",
"version": "0.4.1",
"name": "timesafari",
"version": "0.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "TimeSafari",
"version": "0.4.1",
"name": "timesafari",
"version": "0.4.4",
"dependencies": {
"@capacitor/android": "^6.2.0",
"@capacitor/cli": "^6.2.0",

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "0.4.2",
"version": "0.4.4",
"description": "TimeSafari Desktop Application",
"author": {
"name": "TimeSafari Team"

View File

@@ -0,0 +1,334 @@
<template>
<li>
<!-- Last viewed separator -->
<div
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-6 font-bold text-sm"
v-if="record.jwtId == lastViewedClaimId"
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
You've already seen all the following
</span>
</div>
<div class="bg-slate-100 rounded-t-md border border-slate-300 p-3 sm:p-4">
<div class="flex items-center gap-2 mb-6">
<img
src="https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg"
class="size-8 object-cover rounded-full"
/>
<div>
<h3 class="font-semibold">
{{
record.giver.known ? record.giver.displayName : "Anonymous Giver"
}}
</h3>
<p class="ms-auto text-xs text-slate-500 italic">
{{ friendlyDate }}
</p>
</div>
</div>
<!-- Record Image -->
<div
v-if="record.image"
class="bg-cover mb-6 -mx-3 sm:-mx-4"
:style="`background-image: url(${record.image});`"
>
<a
class="block bg-slate-100/50 backdrop-blur-md px-6 py-4 cursor-pointer"
@click="$emit('viewImage', record.image)"
>
<img
class="w-full h-auto max-w-lg max-h-96 object-contain mx-auto drop-shadow-md"
:src="record.image"
@load="$emit('cacheImage', record.image)"
alt="Activity image"
/>
</a>
</div>
<div class="relative flex justify-between gap-4 max-w-lg mx-auto mb-5">
<!-- Source -->
<div
class="w-28 sm:w-40 text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
>
<div class="relative w-fit mx-auto">
<template v-if="record.giver.profileImageUrl">
<EntityIcon
:profile-image-url="record.giver.profileImageUrl"
:class="[
!record.providerPlanName
? 'rounded-full size-[3rem] sm:size-[4rem] object-cover'
: 'rounded size-[3rem] sm:size-[4rem] object-cover',
]"
/>
</template>
<template v-else>
<!-- Project Icon -->
<template v-if="record.providerPlanName">
<ProjectIcon
:entityId="record.providerPlanName"
:iconSize="48"
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
/>
</template>
<!-- Identicon for DIDs -->
<template v-else-if="record.giver.did">
<img
:src="`https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg`"
class="rounded-full size-[3rem] sm:size-[4rem]"
alt="Identicon"
/>
</template>
<!-- Unknown Person -->
<template v-else>
<fa
icon="person-circle-question"
class="text-slate-300 text-[3rem] sm:text-[4rem]"
/>
</template>
</template>
</div>
<div class="text-xs mt-2 line-clamp-3 sm:line-clamp-2">
<fa
:icon="record.providerPlanName ? 'building' : 'user'"
class="fa-fw text-slate-400"
/>
{{ record.giver.displayName }}
</div>
</div>
<!-- Arrow -->
<div
class="absolute inset-x-28 sm:inset-x-40 mx-2 top-1/2 -translate-y-1/2"
>
<div class="text-sm text-center leading-none font-semibold">
{{ fetchAmount }}
</div>
<div class="flex items-center">
<hr
class="grow border-t-[18px] sm:border-t-[24px] border-slate-300"
/>
<div
class="shrink-0 w-0 h-0 border border-slate-300 border-t-[20px] sm:border-t-[25px] border-t-transparent border-b-[20px] sm:border-b-[25px] border-b-transparent border-s-[27px] sm:border-s-[34px] border-e-0"
></div>
</div>
</div>
<!-- Destination -->
<div
class="w-28 sm:w-40 text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
>
<div class="relative w-fit mx-auto">
<template v-if="record.receiver.profileImageUrl">
<EntityIcon
:profile-image-url="record.receiver.profileImageUrl"
:class="[
!record.recipientProjectName
? 'rounded-full size-[3rem] sm:size-[4rem] object-cover'
: 'rounded size-[3rem] sm:size-[4rem] object-cover',
]"
/>
</template>
<template v-else>
<!-- Project Icon -->
<template v-if="record.recipientProjectName">
<ProjectIcon
:entityId="record.recipientProjectName"
:iconSize="48"
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
/>
</template>
<!-- Identicon for DIDs -->
<template v-else-if="record.receiver.did">
<img
:src="`https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg`"
class="rounded-full size-[3rem] sm:size-[4rem]"
alt="Identicon"
/>
</template>
<!-- Unknown Person -->
<template v-else>
<fa
icon="person-circle-question"
class="text-slate-300 text-[3rem] sm:text-[4rem]"
/>
</template>
</template>
</div>
<div class="text-xs mt-2 line-clamp-3 sm:line-clamp-2">
<fa
:icon="record.recipientProjectName ? 'building' : 'user'"
class="fa-fw text-slate-400"
/>
{{ record.receiver.displayName }}
</div>
</div>
</div>
<!-- Description -->
<p class="font-medium">
<a @click="$emit('loadClaim', record.jwtId)" class="cursor-pointer">
{{ description }}
</a>
</p>
<p class="text-sm">{{ subDescription }}</p>
</div>
<div
class="flex items-center gap-2 text-lg bg-slate-300 rounded-b-md px-3 sm:px-4 py-1 sm:py-2"
>
<a @click="$emit('loadClaim', record.jwtId)" class="cursor-pointer">
<fa icon="circle-info" class="fa-fw text-slate-500" />
</a>
</div>
</li>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { GiveRecordWithContactInfo } from "../types";
import EntityIcon from "./EntityIcon.vue";
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
import { containsHiddenDid } from "../libs/endorserServer";
import ProjectIcon from "./ProjectIcon.vue";
@Component({
components: {
EntityIcon,
ProjectIcon,
},
})
export default class ActivityListItem extends Vue {
@Prop() record!: GiveRecordWithContactInfo;
@Prop() lastViewedClaimId?: string;
@Prop() isRegistered!: boolean;
@Prop() activeDid!: string;
@Prop() confirmerIdList?: string[];
get fetchAmount(): string {
const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
const amount = claim.object?.amountOfThisGood
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
: "";
return amount;
}
private formatParticipantInfo(): string {
const { giver, receiver } = this.record;
// Both participants are known contacts
if (giver.known && receiver.known) {
return `${giver.displayName} gave to ${receiver.displayName}`;
}
// Only giver is known
if (giver.known) {
const recipient = this.record.recipientProjectName
? `the project "${this.record.recipientProjectName}"`
: receiver.displayName;
return `${giver.displayName} gave to ${recipient}`;
}
// Only receiver is known
if (receiver.known) {
const provider = this.record.providerPlanName
? `the project "${this.record.providerPlanName}"`
: giver.displayName;
return `${receiver.displayName} received from ${provider}`;
}
// Neither is known
return this.formatUnknownParticipants();
}
private formatUnknownParticipants(): string {
const { giver, receiver, providerPlanName, recipientProjectName } =
this.record;
if (providerPlanName || recipientProjectName) {
const from = providerPlanName
? `the project "${providerPlanName}"`
: giver.displayName;
const to = recipientProjectName
? `the project "${recipientProjectName}"`
: receiver.displayName;
return `from ${from} to ${to}`;
}
return giver.displayName === receiver.displayName
? `between two who are ${giver.displayName}`
: `from ${giver.displayName} to ${receiver.displayName}`;
}
get description(): string {
const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
if (!claim.description) {
return "something not described";
}
return `${claim.description}`;
}
get subDescription(): string {
const participants = this.formatParticipantInfo();
return `${participants}`;
}
private displayAmount(code: string, amt: number) {
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`;
}
private currencyShortWordForCode(unitCode: string, single: boolean) {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
}
get formattedTimestamp() {
// Add your timestamp formatting logic here
return this.record.timestamp;
}
get canConfirm(): boolean {
if (!this.isRegistered) return false;
if (!isGiveClaimType(this.record.fullClaim?.["@type"])) return false;
if (this.confirmerIdList?.includes(this.activeDid)) return false;
if (this.record.issuerDid === this.activeDid) return false;
if (containsHiddenDid(this.record.fullClaim)) return false;
return true;
}
handleConfirmClick() {
if (!this.canConfirm) {
notifyWhyCannotConfirm(
this.$notify,
this.isRegistered,
this.record.fullClaim?.["@type"],
this.record,
this.activeDid,
this.confirmerIdList,
);
return;
}
this.$emit("confirmClaim", this.record);
}
get friendlyDate(): string {
const date = new Date(this.record.issuedAt);
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
}
</script>

View File

@@ -20,7 +20,7 @@ export type Settings = {
// active Decentralized ID
activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry
apiServer: string; // API server URL
apiServer?: string; // API server URL
filterFeedByNearby?: boolean; // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden

View File

@@ -58,6 +58,7 @@ import {
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImage,
faImagePortrait,
faLeftRight,
faLightbulb,
@@ -87,6 +88,7 @@ import {
faUser,
faUsers,
faXmark,
faBuilding,
} from "@fortawesome/free-solid-svg-icons";
library.add(
@@ -137,6 +139,7 @@ library.add(
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImage,
faImagePortrait,
faLeftRight,
faLightbulb,
@@ -166,6 +169,7 @@ library.add(
faUser,
faUsers,
faXmark,
faBuilding,
);
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";

20
src/types/index.ts Normal file
View File

@@ -0,0 +1,20 @@
export interface GiveRecordWithContactInfo {
jwtId: string;
fullClaim: unknown; // Replace with proper type
giver: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
receiver: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
providerPlanName?: string;
recipientProjectName?: string;
description?: string;
subDescription?: string;
image?: string;
timestamp: string;
}

View File

@@ -352,9 +352,9 @@ export default class DiscoverView extends Vue {
allMyDids: Array<string> = [];
apiServer = "";
isLoading = false;
isLocalActive = true;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = false;
isAnywhereActive = true;
isProjectsActive = true;
isPeopleActive = false;
isSearchVisible = true;
@@ -375,6 +375,11 @@ export default class DiscoverView extends Vue {
didInfo = didInfo;
async mounted() {
this.searchTerms = this.$route.query["searchText"]?.toString() || "";
const searchPeople = !!this.$route.query["searchPeople"];
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = (settings.activeDid as string) || "";
this.apiServer = (settings.apiServer as string) || "";
@@ -386,25 +391,35 @@ export default class DiscoverView extends Vue {
this.allMyDids = await retrieveAccountDids();
this.searchTerms = this.$route.query["searchText"]?.toString() || "";
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Discover,
);
}
if (this.searchBox) {
await this.searchLocal();
// Someday we'll have enough people that we can default to their local area.
// if (this.searchBox) {
// this.isLocalActive = true;
// this.isAnywhereActive = false;
// await this.searchLocal();
//
// const bbox = this.searchBox.bbox;
// this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
// this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
// } else {
const bbox = this.searchBox.bbox;
this.localCenterLat = (bbox.maxLat + bbox.minLat) / 2;
this.localCenterLong = (bbox.eastLong + bbox.westLong) / 2;
if (searchPeople) {
this.isPeopleActive = true;
this.isProjectsActive = false;
this.isMappedActive = true;
this.isAnywhereActive = false;
}
if (this.isMappedActive) {
// The map will be loaded when it's ready
// and if we try to do it here before the map is ready then we get errors.
} else {
this.isLocalActive = false;
this.isMappedActive = false;
this.isAnywhereActive = true;
await this.searchAll();
await this.searchSelected();
}
}
@@ -466,7 +481,7 @@ export default class DiscoverView extends Vue {
} else {
throw JSON.stringify(results);
}
} else {
} else { // people search must be active
this.projects = [];
const profiles: UserProfile[] = results.data;
if (profiles) {

View File

@@ -186,23 +186,23 @@
</div>
<!-- Results List -->
<div class="bg-slate-100 rounded-md px-4 py-3 mt-4 mb-4">
<div class="mt-4 mb-4">
<div class="flex items-center mb-4">
<h2 class="text-xl font-bold">
<h2 class="text-xl font-bold flex items-center gap-4">
Latest Activity
<button @click="openFeedFilters()">
<span class="text-xs text-white">
<fa
v-if="resultsAreFiltered()"
icon="filter"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-1 py-1.5 rounded-md"
/>
<fa
v-else
icon="filter"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-1 py-1.5 rounded-md"
/>
</span>
<button
v-if="resultsAreFiltered()"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
@click="openFeedFilters()"
>
<fa icon="filter" class="fa-fw" />
</button>
<button
v-else
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
@click="openFeedFilters()"
>
<fa icon="filter" class="fa-fw" />
</button>
</h2>
</div>
@@ -249,117 +249,20 @@
</div>
<InfiniteScroll @reached-bottom="loadMoreGives">
<ul id="listLatestActivity" class="border-t border-slate-300">
<li
class="border-b border-slate-300 py-2"
<ul id="listLatestActivity" class="space-y-4">
<ActivityListItem
v-for="record in feedData"
:key="record.jwtId"
>
<div
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
v-if="record.jwtId == feedLastViewedClaimId"
>
You've already seen all the following
</div>
<div class="grid grid-cols-12">
<span class="pt-1 col-span-1 justify-self-start">
<span>
<fa
icon="circle-user"
:class="
computeKnownPersonIconStyleClassNames(
record.giver.known || record.receiver.known,
)
"
@click="toastUser('This involves your contacts.')"
/>
<fa
icon="gift"
class="pl-3 text-slate-500"
@click="toastUser('This is a gift.')"
/>
</span>
</span>
<span class="col-span-10 justify-self-stretch overflow-hidden">
<!-- show giver and/or receiver profiles... which seemed like a good idea but actually adds clutter
<span
v-if="
record.giver.profileImageUrl ||
record.receiver.profileImageUrl
"
>
<EntityIcon
v-if="record.agentDid !== activeDid"
:icon-size="32"
:profile-image-url="record.giver.profileImageUrl"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/>
<fa
v-if="
record.agentDid !== activeDid &&
record.recipientDid !== activeDid &&
!record.fulfillsPlanHandleId
"
icon="ellipsis"
class="text-slate"
/>
<EntityIcon
v-if="
record.recipientDid !== activeDid &&
!record.fulfillsPlanHandleId
"
:iconSize="32"
:profile-image-url="record.receiver.profileImageUrl"
class="inline-block align-middle border border-slate-300 rounded-md ml-1"
/>
</span>
-->
<span class="pl-2 block break-words">
{{ giveDescription(record) }}
</span>
<a @click="onClickLoadClaim(record.jwtId)">
<fa
icon="file-lines"
class="pl-2 text-slate-500 cursor-pointer"
/>
</a>
</span>
<span class="col-span-1 justify-self-end">
<router-link
v-if="record.fulfillsPlanHandleId"
:to="
'/project/' +
encodeURIComponent(record.fulfillsPlanHandleId)
"
>
<fa icon="hammer" class="text-blue-500" />
</router-link>
<router-link
v-if="record.providerPlanHandleId"
:to="
'/project/' +
encodeURIComponent(record.providerPlanHandleId)
"
>
<fa icon="hammer" class="text-blue-500" />
</router-link>
</span>
</div>
<div v-if="record.image" class="w-full">
<div
class="cursor-pointer"
@click="openImageViewer(record.image)"
>
<img
:src="record.image"
class="w-full aspect-[3/2] object-cover rounded-xl mt-2"
alt="shared content"
@load="cacheImageData($event, record.image)"
/>
</div>
</div>
</li>
:record="record"
:lastViewedClaimId="feedLastViewedClaimId"
:isRegistered="isRegistered"
:activeDid="activeDid"
:confirmerIdList="record.confirmerIdList"
@loadClaim="onClickLoadClaim"
@viewImage="openImageViewer"
@cacheImage="cacheImageData"
@confirmClaim="confirmClaim"
/>
</ul>
</InfiniteScroll>
<div v-if="isFeedLoading">
@@ -401,6 +304,7 @@ import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
import ImageViewer from "../components/ImageViewer.vue";
import ActivityListItem from "../components/ActivityListItem.vue";
import {
AppString,
NotificationIface,
@@ -436,6 +340,8 @@ import {
OnboardPage,
registerSaveAndActivatePasskey,
} from "../libs/util";
import * as serverUtil from "../libs/endorserServer";
// import { fa0 } from "@fortawesome/free-solid-svg-icons";
interface GiveRecordWithContactInfo extends GiveSummaryRecord {
giver: {
@@ -466,6 +372,7 @@ interface GiveRecordWithContactInfo extends GiveSummaryRecord {
TopMessage,
UserNameDialog,
ImageViewer,
ActivityListItem,
},
})
export default class HomeView extends Vue {
@@ -559,6 +466,7 @@ export default class HomeView extends Vue {
if (resp.status === 200) {
await updateAccountSettings(this.activeDid, {
isRegistered: true,
...(await retrieveSettingsForActiveAccount()),
});
this.isRegistered = true;
}
@@ -1002,5 +910,67 @@ export default class HomeView extends Vue {
this.selectedImage = imageUrl;
this.isImageViewerOpen = true;
}
async confirmClaim(record: GiveRecordWithContactInfo) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
record.fullClaim,
record.jwtId,
record.handleId,
),
),
);
const confirmationClaim = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
3000,
);
// Refresh the feed to show updated confirmation status
await this.updateAllFeed();
} else {
console.error("Error submitting confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation.",
},
5000,
);
}
},
},
-1,
);
}
}
</script>

View File

@@ -325,74 +325,139 @@
</div>
</div>
<h3 class="text-lg font-bold mb-3 mt-4">Given To This Idea</h3>
<h3 class="text-lg font-bold mt-4">Given To This Idea</h3>
<div v-if="givesToThis.length === 0">
<div v-if="givesToThis.length === 0" class="text-sm">
(None yet. If you've seen something, say something by clicking a
contact above.)
</div>
<ul v-else class="text-sm border-t border-slate-300">
<li
v-for="give in givesToThis"
:key="give.id"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span>
<fa icon="user" class="fa-fw text-slate-400" />
{{
serverUtil.didInfo(
give.agentDid,
activeDid,
allMyDids,
allContacts,
)
<div v-else class="mt-1 text-sm">
<!-- Totals section -->
<div class="mt-1 flex items-center min-h-[1.5rem]">
<div v-if="loadingTotals" class="flex-1">
<fa icon="spinner" class="fa-spin-pulse text-blue-500" />
</div>
<div v-else-if="givesTotalsByUnit.length > 0" class="flex-1">
<span class="font-semibold mr-2 shrink-0">Totals</span>
<span class="whitespace-nowrap overflow-hidden text-ellipsis">
<a
@click="totalsExpanded = !totalsExpanded"
class="cursor-pointer text-blue-500"
>
<!-- just show the hours, or alternatively whatever is first -->
<span v-if="givenTotalHours() > 0">
{{ givenTotalHours() }} {{ libsUtil.UNIT_SHORT["HUR"] }}
</span>
<span v-else>
{{ givesTotalsByUnit[0].amount }}
{{ libsUtil.UNIT_SHORT[givesTotalsByUnit[0].unit] }}
</span>
<span v-if="givesTotalsByUnit.length > 1">...</span>
<span>
<fa
:icon="totalsExpanded ? 'chevron-up' : 'chevron-right'"
class="fa-fw text-xs ml-1"
/>
</span>
</a>
<!-- show the full list when expanded -->
<div v-if="totalsExpanded">
<div
v-for="total in givesTotalsByUnit"
:key="total.unit"
class="ml-2"
>
<fa
:icon="libsUtil.iconForUnitCode(total.unit)"
class="fa-fw text-slate-400 mr-1"
/>
{{ total.amount }} {{ libsUtil.UNIT_LONG[total.unit] }}
</div>
</div>
</span>
</div>
<div v-else>
<span class="font-semibold mr-2 shrink-0">
{{ givesToThis.length }}{{ givesHitLimit ? "+" : "" }} record{{
givesToThis.length === 1 ? "" : "s"
}}
</span>
<span v-if="give.amount" class="whitespace-nowrap">
<fa
:icon="libsUtil.iconForUnitCode(give.unit)"
class="fa-fw text-slate-400"
/>{{ give.amount }}
</span>
</div>
<div class="text-slate-500">
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
<div class="flex justify-between">
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a>
</div>
<a
v-if="
checkIsConfirmable(give) &&
!recentlyCheckedAndUnconfirmableJwts.includes(give.jwtId)
"
@click="deepCheckConfirmable(give)"
>
<fa icon="circle-check" class="text-blue-500 cursor-pointer" />
</a>
<a v-else-if="checkingConfirmationForJwtId === give.jwtId">
<fa icon="spinner" class="fa-spin-pulse" />
</a>
<a v-else @click="shallowNotifyWhyCannotConfirm(give)">
<fa icon="circle-check" class="text-slate-500 cursor-pointer" />
</a>
</div>
<div v-if="give.fullClaim.image" class="flex justify-center">
<a :href="give.fullClaim.image" target="_blank">
<img :src="give.fullClaim.image" class="h-24 mt-2 rounded-xl" />
</a>
</div>
</li>
</ul>
<!-- List of gives -->
<ul class="mt-2 text-sm border-t border-slate-300">
<li
v-for="give in givesToThis"
:key="give.id"
class="py-1.5 border-b border-slate-300"
>
<div class="flex justify-between gap-4">
<span>
<fa icon="user" class="fa-fw text-slate-400" />
{{
serverUtil.didInfo(
give.agentDid,
activeDid,
allMyDids,
allContacts,
)
}}
</span>
<span v-if="give.amount" class="whitespace-nowrap">
<fa
:icon="libsUtil.iconForUnitCode(give.unit)"
class="fa-fw text-slate-400"
/>{{ give.amount }}
</span>
</div>
<div class="text-slate-500">
<fa icon="calendar" class="fa-fw text-slate-400" />
{{ give.issuedAt?.substring(0, 10) }}
</div>
<div v-if="give.description" class="text-slate-500">
<fa icon="comment" class="fa-fw text-slate-400" />
{{ give.description }}
</div>
<div class="flex justify-between">
<a @click="onClickLoadClaim(give.jwtId)">
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
</a>
<a
v-if="
checkIsConfirmable(give) &&
!recentlyCheckedAndUnconfirmableJwts.includes(give.jwtId)
"
@click="deepCheckConfirmable(give)"
>
<fa
icon="circle-check"
class="text-blue-500 cursor-pointer"
/>
</a>
<a v-else-if="checkingConfirmationForJwtId === give.jwtId">
<fa icon="spinner" class="fa-spin-pulse" />
</a>
<a v-else @click="shallowNotifyWhyCannotConfirm(give)">
<fa
icon="circle-check"
class="text-slate-500 cursor-pointer"
/>
</a>
</div>
<div v-if="give.fullClaim.image" class="flex justify-center">
<a :href="give.fullClaim.image" target="_blank">
<img
:src="give.fullClaim.image"
class="h-24 mt-2 rounded-xl"
/>
</a>
</div>
</li>
</ul>
</div>
<div v-if="givesHitLimit" class="text-center text-blue-500">
<button @click="loadGives()">Load More</button>
</div>
@@ -554,6 +619,7 @@ export default class ProjectViewView extends Vue {
givesHitLimit = false;
givesProvidedByThis: Array<GiveSummaryRecord> = [];
givesProvidedByHitLimit = false;
givesTotalsByUnit: Array<{ unit: string; amount: number }> = [];
imageUrl = "";
isRegistered = false;
issuer = "";
@@ -564,6 +630,7 @@ export default class ProjectViewView extends Vue {
} | null = null;
issuerVisibleToDids: Array<string> = [];
latitude = 0;
loadingTotals = false;
longitude = 0;
name = "";
offersToThis: Array<OfferSummaryRecord> = [];
@@ -571,6 +638,7 @@ export default class ProjectViewView extends Vue {
projectId = ""; // handle ID
recentlyCheckedAndUnconfirmableJwts: string[] = [];
startTime = "";
totalsExpanded = false;
truncatedDesc = "";
truncateLength = 40;
url = "";
@@ -609,6 +677,7 @@ export default class ProjectViewView extends Vue {
this.projectId = decodeURIComponent(pathParam);
}
this.loadProject(this.projectId, this.activeDid);
this.loadTotals();
}
onEditClick() {
@@ -1207,5 +1276,56 @@ export default class ProjectViewView extends Vue {
this.allMyDids,
);
}
async loadTotals() {
this.loadingTotals = true;
const url =
this.apiServer +
"/api/v2/report/givesToPlans?planIds=" +
encodeURIComponent(JSON.stringify([this.projectId]));
const headers = await serverUtil.getHeaders(this.activeDid);
try {
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data.data) {
// Calculate totals by unit
const totals: { [key: string]: number } = {};
resp.data.data.forEach((give: GiveSummaryRecord) => {
const amount = give.fullClaim.object?.amountOfThisGood;
const unit = give.fullClaim.object?.unitCode;
if (amount && unit) {
totals[unit] = (totals[unit] || 0) + amount;
}
});
// Convert totals object to array format
this.givesTotalsByUnit = Object.entries(totals).map(
([unit, amount]) => ({
unit,
amount,
}),
);
}
} catch (error) {
console.error("Error loading totals:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to load totals for this project.",
},
5000,
);
} finally {
this.loadingTotals = false;
}
}
givenTotalHours(): number {
return (
this.givesTotalsByUnit.find((total) => total.unit === "HUR")?.amount || 0
);
}
}
</script>