stash
This commit is contained in:
parent
2f8683add1
commit
cfcc2c15d3
15 changed files with 644 additions and 77 deletions
77
frontend/src/components/UserNameTag.vue
Normal file
77
frontend/src/components/UserNameTag.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="d-flex align-items-center">
|
||||
<authenticated-image v-if="profilePictureUrl"
|
||||
:src="profilePictureUrl"
|
||||
:owner="displayUsername"
|
||||
:alt="displayUsername"
|
||||
:title="displayUsername"
|
||||
img-class="rounded-circle mr-2"
|
||||
:width="width"
|
||||
:height="height"
|
||||
fit="cover"/>
|
||||
<span>{{ displayUsername }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import {mapGetters, mapActions} from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'UserNameTag',
|
||||
components: {
|
||||
AuthenticatedImage
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 32
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 32
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['allUserProfiles']),
|
||||
displayUsername() {
|
||||
return this.user.username || this.user.owner || '';
|
||||
},
|
||||
profilePictureUrl() {
|
||||
// Check for explicitly set profile picture from allUserProfiles getter
|
||||
const storedProfile = this.allUserProfiles[this.displayUsername];
|
||||
if (storedProfile?.profile_picture?.name) {
|
||||
return storedProfile.profile_picture.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchFriendProfile']),
|
||||
fetchProfileIfNeeded() {
|
||||
// Fetch profile if not already in store
|
||||
if (!this.allUserProfiles[this.displayUsername] && this.displayUsername) {
|
||||
this.fetchFriendProfile({username: this.displayUsername});
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchProfileIfNeeded();
|
||||
},
|
||||
watch: {
|
||||
displayUsername() {
|
||||
this.fetchProfileIfNeeded();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
|
@ -282,6 +282,23 @@ class ServerSet {
|
|||
function ServerSetUnion(serverSets) {
|
||||
return new Proxy(serverSets, {
|
||||
get: function (target, prop, receiver) {
|
||||
// Note: 'add' must be checked before the generic funcs-forwarding branch below,
|
||||
// because ServerSet.prototype also defines its own `add(server)` method (for
|
||||
// adding a raw server address string to a single ServerSet). Without this check
|
||||
// first, `funcs.includes('add')` would always be true and the union-specific
|
||||
// "add a ServerSet to this union" logic below would never be reached.
|
||||
if (prop === 'add') {
|
||||
return function (serverset) {
|
||||
if (!serverset || !(serverset instanceof ServerSet)) {
|
||||
throw new Error('no serverset')
|
||||
}
|
||||
if (target.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
|
||||
console.warn('serverset already in union', serverset)
|
||||
return
|
||||
}
|
||||
target.push(serverset)
|
||||
}
|
||||
}
|
||||
const funcs = Object.getOwnPropertyNames(ServerSet.prototype)
|
||||
if (funcs.includes(prop)) {
|
||||
return async function (...args) {
|
||||
|
|
@ -295,17 +312,6 @@ function ServerSetUnion(serverSets) {
|
|||
throw new Error('all servers failed')
|
||||
}
|
||||
}
|
||||
} else if (prop === 'add') {
|
||||
return function (serverset) {
|
||||
if (!serverset || !(serverset instanceof ServerSet)) {
|
||||
throw new Error('no serverset')
|
||||
}
|
||||
if (target.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
|
||||
console.warn('serverset already in union', serverset)
|
||||
return
|
||||
}
|
||||
target.push(serverset)
|
||||
}
|
||||
} else {
|
||||
return target[prop]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export default createStore({
|
|||
keypair: null,
|
||||
remember: false,
|
||||
friends: [],
|
||||
friendProfiles: {},
|
||||
item_map: {},
|
||||
home_servers: null,
|
||||
all_friends_servers: null,
|
||||
|
|
@ -97,6 +98,12 @@ export default createStore({
|
|||
setFriends(state, friends) {
|
||||
state.friends = friends;
|
||||
},
|
||||
setFriendProfile(state, {username, profile}) {
|
||||
state.friendProfiles = {...state.friendProfiles, [username]: profile};
|
||||
},
|
||||
setFriendProfiles(state, profiles) {
|
||||
state.friendProfiles = profiles;
|
||||
},
|
||||
setHomeServers(state, home_servers) {
|
||||
state.home_servers = home_servers;
|
||||
},
|
||||
|
|
@ -326,11 +333,21 @@ export default createStore({
|
|||
const home = await dispatch('getHomeServers')
|
||||
servers.add(home)
|
||||
for (const friend of friends) {
|
||||
const s = await dispatch('lookupServer', {username: friend.username})
|
||||
servers.add(new ServerSet(s, state.unreachable_neighbors))
|
||||
try {
|
||||
const s = await dispatch('lookupServer', {username: friend.username})
|
||||
servers.add(new ServerSet(s, state.unreachable_neighbors))
|
||||
} catch (e) {
|
||||
// Don't let a single unresolvable/unreachable friend abort the whole
|
||||
// search/federation lookup - just skip them and continue.
|
||||
console.error('could not resolve server for friend', friend.username, e)
|
||||
}
|
||||
}
|
||||
return servers
|
||||
})()
|
||||
})().catch(e => {
|
||||
// Don't cache a permanently-rejected promise - allow a retry next time.
|
||||
state.all_friends_servers = null
|
||||
throw e
|
||||
})
|
||||
commit('setAllFriendsServers', promise)
|
||||
return promise
|
||||
},
|
||||
|
|
@ -376,6 +393,20 @@ export default createStore({
|
|||
commit('setFriends', data)
|
||||
return data
|
||||
},
|
||||
async fetchFriendProfile({commit, dispatch, getters}, {username}) {
|
||||
try {
|
||||
const servers = await dispatch('getFriendServers', {username});
|
||||
// username here is already a full handle (username@domain), see FriendSerializer
|
||||
const profile = await servers.get(getters.signAuth, '/auth/user/' + username + '/');
|
||||
if (profile) {
|
||||
commit('setFriendProfile', {username, profile});
|
||||
}
|
||||
return profile;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch profile for ${username}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async fetchFriendRequests({state, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.get(getters.signAuth, '/api/friendrequests/')
|
||||
|
|
@ -612,6 +643,14 @@ export default createStore({
|
|||
|
||||
return state.user !== null && (state.token !== null || state.keypair !== null)
|
||||
},
|
||||
allUserProfiles(state) {
|
||||
// Combine user's own profile with all friend profiles
|
||||
const combined = {...state.friendProfiles};
|
||||
if (state.user && state.user_profile) {
|
||||
combined[state.user] = state.user_profile;
|
||||
}
|
||||
return combined;
|
||||
},
|
||||
signAuth(state) {
|
||||
return createSignAuth(state.user, state.keypair.signSk)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -36,9 +36,11 @@
|
|||
<button class="btn btn-primary" @click="tryRequestFriend">Send request</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="friend in friendslist" :key="friend.name">
|
||||
<td>{{ friend.username }}</td>
|
||||
<td class="d-none d-md-table-cell">{{ friend.server.join(', ')}}</td>
|
||||
<tr v-for="friend in friendslist" :key="friend.name">
|
||||
<td>
|
||||
<user-name-tag :user="friend"/>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">{{ friend.server.join(', ')}}</td>
|
||||
<td class="table-action">
|
||||
<!--a href="#" class="align-middle">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
|
|
@ -76,7 +78,7 @@
|
|||
<tr v-for="request in requests" :key="request.befriender">
|
||||
<td>{{ request.befriender }}</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
{{ request.befriender_public_key.slice(0, 32) }}...
|
||||
{{ request.befriender_public_key?.slice(0, 32) ?? 'N/A' }}...
|
||||
</td>
|
||||
<td class="table-action">
|
||||
<button class="btn btn-sm btn-success" @click="tryAcceptFriend(request)">
|
||||
|
|
@ -100,14 +102,18 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters} from "vuex";
|
||||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import UserNameTag from "@/components/UserNameTag.vue";
|
||||
|
||||
export default {
|
||||
name: 'Inventory',
|
||||
components: {
|
||||
BaseLayout,
|
||||
AuthenticatedImage,
|
||||
UserNameTag,
|
||||
...BIcons
|
||||
},
|
||||
data() {
|
||||
|
|
@ -119,6 +125,7 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['user']),
|
||||
username() {
|
||||
return this.$route.params.username
|
||||
},
|
||||
|
|
@ -128,12 +135,15 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
...mapActions(['fetchFriends', "lookupServer", "requestFriend", "acceptFriend", "fetchFriendRequests",
|
||||
"declineFriend", "dropFriend"]),
|
||||
"declineFriend", "dropFriend", "getFriendServers", "fetchFriendProfile"]),
|
||||
fetchContent() {
|
||||
this.fetchFriends().then((friends) => {
|
||||
friends.map((friend) => {
|
||||
// Handle both object and array formats
|
||||
const friendsList = Array.isArray(friends) ? friends : Object.values(friends);
|
||||
friendsList.map((friend) => {
|
||||
this.lookupServer(friend).then((server) => {
|
||||
this.friends[friend.username] = {...friend, server: server}
|
||||
this.fetchFriendProfile({username: friend.username});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,45 +4,70 @@
|
|||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">Search Inventories</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<SearchBox @change=""/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Results for "{{ query }}"</h5>
|
||||
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
|
||||
<b-icon-list></b-icon-list>
|
||||
</button>
|
||||
<button v-else @click="layout = 'grid'" class="btn">
|
||||
<b-icon-grid></b-icon-grid>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%;">Name</th>
|
||||
<th style="width:25%">Owner</th>
|
||||
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in search_results" :key="item.id">
|
||||
<td>
|
||||
<router-link :to="`/inventory/${item.id}`">{{ item.name }}</router-link>
|
||||
</td>
|
||||
<td>{{ item.owner }}</td>
|
||||
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
|
||||
<td class="table-action">
|
||||
<!--<router-link :to="`/inventory/${item.id}/edit`">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
</router-link>
|
||||
<a :href="`/inventory/${item.id}/delete`"
|
||||
@click.prevent="deleteInventoryItem(item)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</a>-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="table table-striped" v-if="layout === 'table'">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%;">Name</th>
|
||||
<th style="width:25%">Owner</th>
|
||||
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in search_results" :key="item.id">
|
||||
<td>
|
||||
<router-link :to="`/inventory/${item.id}`">{{ item.name }}</router-link>
|
||||
</td>
|
||||
<td>
|
||||
<user-name-tag :user="item"/>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
|
||||
<td class="table-action">
|
||||
<!--<router-link :to="`/inventory/${item.id}/edit`">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
</router-link>
|
||||
<a :href="`/inventory/${item.id}/delete`"
|
||||
@click.prevent="deleteInventoryItem(item)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</a>-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card-body" v-else>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in search_results"
|
||||
:key="item.id">
|
||||
<div class="card">
|
||||
<authenticated-image v-if="only_images(item.files).length > 0"
|
||||
:src="only_images(item.files)[0].name"
|
||||
:owner="only_images(item.files)[0].owner"
|
||||
class="card-img-top img-preview"/>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-0">
|
||||
<router-link :to="`/inventory/${item.id}`">
|
||||
{{ item.name }}
|
||||
</router-link></h5>
|
||||
<div class="card-text text-black-50">
|
||||
<user-name-tag :user="item" :width="24" :height="24"/>
|
||||
<br>
|
||||
<span class="float-right">{{ item.owned_quantity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -57,11 +82,15 @@ import {mapActions} from 'vuex';
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import SearchBox from "@/components/SearchBox.vue";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import UserNameTag from "@/components/UserNameTag.vue";
|
||||
|
||||
export default {
|
||||
name: 'Search',
|
||||
components: {
|
||||
SearchBox,
|
||||
AuthenticatedImage,
|
||||
UserNameTag,
|
||||
...BIcons,
|
||||
BaseLayout
|
||||
},
|
||||
|
|
@ -74,19 +103,37 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
search_results: [],
|
||||
layout: 'table'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchSearchResults']),
|
||||
only_images(files) {
|
||||
if (!files) return [];
|
||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||
},
|
||||
loadResults() {
|
||||
this.fetchSearchResults({query: this.query}).then((results) => {
|
||||
this.search_results = results;
|
||||
});
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.fetchSearchResults({query: this.query}).then((results) => {
|
||||
this.search_results = results;
|
||||
});
|
||||
watch: {
|
||||
query: {
|
||||
immediate: false,
|
||||
handler() {
|
||||
this.loadResults();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadResults();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.img-preview {
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue