1
0
mirror of https://github.com/TandoorRecipes/recipes.git synced 2026-01-11 00:58:32 -05:00

overhauld space management and settings system

This commit is contained in:
vabene1111
2025-09-14 08:48:49 +02:00
parent f722d24eaa
commit 4f248afe76
46 changed files with 221 additions and 361 deletions

View File

@@ -368,10 +368,10 @@ class AiLogSerializer(serializers.ModelSerializer):
class SpaceSerializer(WritableNestedModelSerializer):
created_by = UserSerializer(read_only=True)
user_count = serializers.SerializerMethodField('get_user_count')
recipe_count = serializers.SerializerMethodField('get_recipe_count')
file_size_mb = serializers.SerializerMethodField('get_file_size_mb')
ai_monthly_credits_used = serializers.SerializerMethodField('get_ai_monthly_credits_used')
user_count = serializers.SerializerMethodField('get_user_count', read_only=True)
recipe_count = serializers.SerializerMethodField('get_recipe_count', read_only=True)
file_size_mb = serializers.SerializerMethodField('get_file_size_mb', read_only=True)
ai_monthly_credits_used = serializers.SerializerMethodField('get_ai_monthly_credits_used', read_only=True)
ai_default_provider = AiProviderSerializer(required=False, allow_null=True)
food_inherit = FoodInheritFieldSerializer(many=True, required=False)
image = UserFileViewSerializer(required=False, many=False, allow_null=True)
@@ -413,8 +413,8 @@ class SpaceSerializer(WritableNestedModelSerializer):
name = None
if 'name' in validated_data:
name = validated_data['name']
space = create_space_for_user(self.context['request'].user, name)
return space
user_space = create_space_for_user(self.context['request'].user, name)
return user_space.space
def update(self, instance, validated_data):
if 'ai_enabled' in validated_data and not self.context['request'].user.is_superuser:

View File

@@ -548,7 +548,7 @@ class SpaceViewSet(LoggingMixin, viewsets.ModelViewSet):
serializer_class = SpaceSerializer
permission_classes = [IsReadOnlyDRF & CustomIsGuest | CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope]
pagination_class = DefaultPagination
http_method_names = ['get', 'post', 'patch']
http_method_names = ['get', 'post', 'put', 'patch']
def get_queryset(self):
return self.queryset.filter(

View File

@@ -29,8 +29,6 @@ let routes = [
{path: 'meal-plan', component: () => import("@/components/settings/MealPlanSettings.vue"), name: 'MealPlanSettings', meta: {title: 'Settings'}},
{path: 'search', component: () => import("@/components/settings/SearchSettings.vue"), name: 'SearchSettings', meta: {title: 'Settings'}},
{path: 'space', component: () => import("@/components/settings/SpaceSettings.vue"), name: 'SpaceSettings', meta: {title: 'Settings'}},
{path: 'space-members', component: () => import("@/components/settings/SpaceMemberSettings.vue"), name: 'SpaceMemberSettings', meta: {title: 'Settings'}},
{path: 'user-space', component: () => import("@/components/settings/UserSpaceSettings.vue"), name: 'UserSpaceSettings', meta: {title: 'Settings'}},
{path: 'open-data-import', component: () => import("@/components/settings/OpenDataImportSettings.vue"), name: 'OpenDataImportSettings', meta: {title: 'Settings'}},
{path: 'export', component: () => import("@/components/settings/ExportDataSettings.vue"), name: 'ExportDataSettings', meta: {title: 'Settings'}},
{path: 'api', component: () => import("@/components/settings/ApiSettings.vue"), name: 'ApiSettings', meta: {title: 'Settings'}},

View File

@@ -0,0 +1,72 @@
<template>
<v-row v-if="props.space.name != undefined">
<v-col cols="12" md="4">
<v-card :to="{name: 'SearchPage'}">
<v-card-title><i class="fa-solid fa-book"></i> {{ $t('Recipes') }}</v-card-title>
<v-card-text>{{ $n(props.space.recipeCount) }} / {{ props.space.maxRecipes == 0 ? '∞' : $n(props.space.maxRecipes) }}</v-card-text>
<v-progress-linear :color="isSpaceAboveRecipeLimit(props.space) ? 'error' : 'success'" height="10"
:model-value="(props.space.recipeCount / props.space.maxRecipes) * 100"></v-progress-linear>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card :to="{name: 'ModelListPage', params: {model: 'UserSpace'}}">
<v-card-title><i class="fa-solid fa-users"></i> {{ $t('Users') }}</v-card-title>
<v-card-text>{{ $n(props.space.userCount) }} / {{ props.space.maxUsers == 0 ? '∞' : $n(props.space.maxUsers) }}</v-card-text>
<v-progress-linear :color="isSpaceAboveUserLimit(props.space) ? 'error' : 'success'" height="10"
:model-value="(props.space.userCount / props.space.maxUsers) * 100"></v-progress-linear>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card :to="{name: 'ModelListPage', params: {model: 'UserFile'}}">
<v-card-title><i class="fa-solid fa-file"></i> {{ $t('Files') }}</v-card-title>
<v-card-text v-if="props.space.maxFileStorageMb > -1">{{ $n(Math.round(props.space.fileSizeMb)) }} /
{{ props.space.maxFileStorageMb == 0 ? '' : $n(props.space.maxFileStorageMb) }}
MB
</v-card-text>
<v-card-text v-if="props.space.maxFileStorageMb == -1">{{ $t('file_upload_disabled') }}</v-card-text>
<v-progress-linear v-if="props.space.maxFileStorageMb > -1" :color="isSpaceAboveStorageLimit(props.space) ? 'error' : 'success'" height="10"
:model-value="(props.space.fileSizeMb / props.space.maxFileStorageMb) * 100"></v-progress-linear>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card :to="{name: 'ModelListPage', params: {model: 'AiLog'}}">
<v-card-title><i class="fa-solid hand-holding-dollar"></i> {{ $t('MonthlyCredits') }}</v-card-title>
<v-card-text>{{ $n(props.space.aiMonthlyCreditsUsed) }} / {{ $n(props.space.aiCreditsMonthly) }} {{ $t('Credits') }}
</v-card-text>
<v-progress-linear :model-value="props.space.aiMonthlyCreditsUsed" :max="props.space.aiCreditsMonthly" height="10"
></v-progress-linear>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card :to="{name: 'ModelListPage', params: {model: 'AiLog'}}">
<v-card-title><i class="fa-solid hand-holding-dollar"></i> {{ $t('AiCreditsBalance') }}</v-card-title>
<v-card-text>{{ $n(props.space.aiCreditsBalance) }} {{ $t('Credits') }}
</v-card-text>
<v-progress-linear height="10"
></v-progress-linear>
</v-card>
</v-col>
</v-row>
</template>
<script setup lang="ts">
import {PropType} from "vue";
import {Space} from "@/openapi";
import {isSpaceAboveRecipeLimit, isSpaceAboveStorageLimit, isSpaceAboveUserLimit} from "@/utils/logic_utils.ts";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
const props = defineProps({
space: {type: {} as PropType<Space>, required: true},
})
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,47 @@
<template>
<v-alert color="primary" variant="tonal" v-if="useUserPreferenceStore().serverSettings.hosted">
<v-alert-title>
<v-row>
<v-col>
<v-avatar image="../../assets/logo_color.svg" class="me-2"></v-avatar>
{{ $t('ThankYou') }}!
</v-col>
<v-col>
<v-btn color="primary" class="float-right" href="https://tandoor.dev/manage" target="_blank">{{ $t('ManageSubscription') }}</v-btn>
</v-col>
</v-row>
</v-alert-title>
<p class="mt-2">{{ $t('ThanksTextHosted') }}</p>
</v-alert>
<v-alert color="primary" variant="tonal" v-if="!useUserPreferenceStore().serverSettings.hosted">
<v-alert-title>
<v-row>
<v-col>
<v-avatar image="../../assets/logo_color.svg" class="me-2"></v-avatar>
{{ $t('ThankYou') }}!
</v-col>
<v-col>
<v-btn color="primary" class="float-right" href="https://github.com/sponsors/vabene1111" target="_blank"><i class="fa-brands fa-github"></i> GitHub Sponsors
</v-btn>
</v-col>
</v-row>
</v-alert-title>
<p class="mt-2">{{ $t('ThanksTextSelfhosted') }}</p>
</v-alert>
</template>
<script setup lang="ts">
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
</script>
<style scoped>
</style>

View File

@@ -27,7 +27,9 @@
<user-file-field v-model="editingObj.image" :label="$t('Image')" :hint="$t('CustomImageHelp')" persistent-hint></user-file-field>
<v-textarea v-model="editingObj.message" :label="$t('Message')"></v-textarea>
<v-textarea v-model="editingObj.message" :label="$t('Message')" clearable></v-textarea>
<space-limits-info :space="editingObj" :show-thank-you="false"></space-limits-info>
</v-form>
</v-tabs-window-item>
@@ -81,13 +83,14 @@
<script setup lang="ts">
import {onMounted, PropType, ref, watch} from "vue";
import {ConnectorConfig, Space} from "@/openapi";
import {ApiApi, ConnectorConfig, Space} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
import UserFileField from "@/components/inputs/UserFileField.vue";
import ModelSelect from "@/components/inputs/ModelSelect.vue";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
import editor from "mavon-editor";
import SpaceLimitsInfo from "@/components/display/SpaceLimitsInfo.vue";
const props = defineProps({
item: {type: {} as PropType<Space>, required: false, default: null},

View File

@@ -2,7 +2,10 @@
<v-form>
<p class="text-h6">{{ $t('Profile') }}</p>
<v-divider class="mb-3"></v-divider>
<v-text-field :label="$t('Username')" v-model="user.username" disabled :hint="$t('theUsernameCannotBeChanged')" persistent-hint></v-text-field>
<thank-you-note></thank-you-note>
<v-text-field class="mt-3" :label="$t('Username')" v-model="user.username" disabled :hint="$t('theUsernameCannotBeChanged')" persistent-hint></v-text-field>
<!-- <v-label>Avatar</v-label><br/>-->
<!-- <v-avatar class="mt-3 mb-3" style="height: 10vh; width: 10vh" color="info">V</v-avatar> Feature coming in a future Version of Tandoor.-->
@@ -39,6 +42,7 @@ import {ApiApi, User} from "@/openapi";
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import {useDjangoUrls} from "@/composables/useDjangoUrls";
import ThankYouNote from "@/components/display/ThankYouNote.vue";
const {getDjangoUrl} = useDjangoUrls()

View File

@@ -1,122 +0,0 @@
<template>
<v-form>
<p class="text-h6">{{ $t('SpaceMembers') }}</p>
<v-divider></v-divider>
<p class="text-subtitle-2">{{ $t('SpaceMemberHelp') }}</p>
<v-data-table :items="spaceUserSpaces" :headers="userTableHeaders" density="compact" :hide-default-footer="spaceUserSpaces.length < 10" class="mt-3">
<template #item.groups="{item}">
<span v-for="g in item.groups">{{ g.name }}&nbsp;</span>
</template>
<template #item.edit="{item}">
<v-btn color="edit" size="small" v-if="item.user.id != useUserPreferenceStore().activeSpace.createdBy.id">
<v-icon icon="$edit"></v-icon>
<model-edit-dialog model="UserSpace" :item="item" @delete="deleteUserSpace(item)" class="mt-2"></model-edit-dialog>
</v-btn>
<v-chip color="edit" v-else>{{ $t('Owner') }}</v-chip>
</template>
</v-data-table>
<p class="text-h6 mt-3">{{ $t('Invites') }}
<v-btn size="small" class="float-right" prepend-icon="$create" color="create">
{{ $t('New') }}
<model-edit-dialog model="InviteLink" @delete="deleteInviteLink" @create="item => spaceInviteLinks.push(item)" class="mt-2"></model-edit-dialog>
</v-btn>
</p>
<v-divider class="mb-3"></v-divider>
<v-data-table :items="spaceInviteLinks" :headers="inviteTableHeaders" density="compact" :hide-default-footer="spaceInviteLinks.length < 10">
<template #item.reusable="{item}">
<v-icon icon="fa-solid fa-check" color="success" v-if="item.reusable"></v-icon>
<v-icon icon="fa-solid fa-times" color="error" v-if="!item.reusable"></v-icon>
</template>
<template #item.edit="{item}">
<btn-copy size="small" :copy-value="inviteLinkUrl(item)" class="me-1"></btn-copy>
<v-btn color="edit" size="small">
<v-icon icon="$edit"></v-icon>
<model-edit-dialog model="InviteLink" :item="item" @delete="deleteInviteLink(item)" class="mt-2"></model-edit-dialog>
</v-btn>
</template>
</v-data-table>
</v-form>
</template>
<script setup lang="ts">
import {onMounted, ref} from "vue";
import {ApiApi, InviteLink, UserSpace} from "@/openapi";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
import {useI18n} from "vue-i18n";
import BtnCopy from "@/components/buttons/BtnCopy.vue";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
import {useDjangoUrls} from "@/composables/useDjangoUrls.ts";
const {t} = useI18n()
const spaceUserSpaces = ref([] as UserSpace[])
const spaceInviteLinks = ref([] as InviteLink[])
const userTableHeaders = [
{title: t('Username'), key: 'user.username'},
{title: t('Role'), key: 'groups'},
{title: t('Edit'), key: 'edit', align: 'end'},
]
const inviteTableHeaders = [
{title: 'ID', key: 'id'},
{title: t('Email'), key: 'email'},
{title: t('Role'), key: 'group.name'},
{title: t('Reusable'), key: 'reusable'},
{title: t('Edit'), key: 'edit', align: 'end'},
]
onMounted(() => {
const api = new ApiApi()
api.apiUserSpaceList().then(r => {
spaceUserSpaces.value = r.results
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
api.apiInviteLinkList({unused: true}).then(r => {
spaceInviteLinks.value = r.results
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
})
/**
* delete userspace from client list (database handled by editor)
* @param userSpace UserSpace object that was deleted
*/
function deleteUserSpace(userSpace: UserSpace) {
spaceUserSpaces.value.splice(spaceUserSpaces.value.indexOf(userSpace) - 1, 1)
}
/**
* delete invite link from client list (database handled by editor)
* @param inviteLink InviteLink object that was deleted
*/
function deleteInviteLink(inviteLink: InviteLink) {
spaceInviteLinks.value.splice(spaceInviteLinks.value.indexOf(inviteLink) - 1, 1)
}
/**
* returns url for invite link
* @param inviteLink InviteLink object to create url for
*/
function inviteLinkUrl(inviteLink: InviteLink) {
return useDjangoUrls().getDjangoUrl(`/invite/${inviteLink.uuid}`)
}
</script>
<style scoped>
</style>

View File

@@ -3,172 +3,17 @@
<p class="text-h6">{{ useUserPreferenceStore().activeSpace.name }}</p>
<v-divider class="mb-3"></v-divider>
<v-row v-if="space.name != undefined">
<v-col cols="12" md="4">
<v-card>
<v-card-title><i class="fa-solid fa-book"></i> {{ $t('Recipes') }}</v-card-title>
<v-card-text>{{ $n(space.recipeCount) }} / {{ space.maxRecipes == 0 ? '∞' : $n(space.maxRecipes) }}</v-card-text>
<v-progress-linear :color="isSpaceAboveRecipeLimit(space) ? 'error' : 'success'" height="10"
:model-value="(space.recipeCount / space.maxRecipes) * 100"></v-progress-linear>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card>
<v-card-title><i class="fa-solid fa-users"></i> {{ $t('Users') }}</v-card-title>
<v-card-text>{{ $n(space.userCount) }} / {{ space.maxUsers == 0 ? '∞' : $n(space.maxUsers) }}</v-card-text>
<v-progress-linear :color="isSpaceAboveUserLimit(space) ? 'error' : 'success'" height="10"
:model-value="(space.userCount / space.maxUsers) * 100"></v-progress-linear>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card>
<v-card-title><i class="fa-solid fa-file"></i> {{ $t('Files') }}</v-card-title>
<v-card-text v-if="space.maxFileStorageMb > -1">{{ $n(Math.round(space.fileSizeMb)) }} / {{ space.maxFileStorageMb == 0 ? '' : $n(space.maxFileStorageMb) }}
MB
</v-card-text>
<v-card-text v-if="space.maxFileStorageMb == -1">{{ $t('file_upload_disabled') }}</v-card-text>
<v-progress-linear v-if="space.maxFileStorageMb > -1" :color="isSpaceAboveStorageLimit(space) ? 'error' : 'success'" height="10"
:model-value="(space.fileSizeMb / space.maxFileStorageMb) * 100"></v-progress-linear>
</v-card>
</v-col>
</v-row>
<v-divider class="mt-3 mb-3"></v-divider>
<v-alert color="primary" variant="tonal" v-if="useUserPreferenceStore().serverSettings.hosted">
<v-alert-title>
<v-row>
<v-col>
<v-avatar image="../../assets/logo_color.svg" class="me-2"></v-avatar>
{{ $t('ThankYou') }}!
</v-col>
<v-col>
<v-btn color="primary" class="float-right" href="https://tandoor.dev/manage" target="_blank">{{ $t('ManageSubscription') }}</v-btn>
</v-col>
</v-row>
</v-alert-title>
<p class="mt-2">{{ $t('ThanksTextHosted') }}</p>
</v-alert>
<v-alert color="primary" variant="tonal" v-if="!useUserPreferenceStore().serverSettings.hosted">
<v-alert-title>
<v-row>
<v-col>
<v-avatar image="../../assets/logo_color.svg" class="me-2"></v-avatar>
{{ $t('ThankYou') }}!
</v-col>
<v-col>
<v-btn color="primary" class="float-right" href="https://github.com/sponsors/vabene1111" target="_blank"><i class="fa-brands fa-github"></i> GitHub Sponsors
</v-btn>
</v-col>
</v-row>
</v-alert-title>
<p class="mt-2">{{ $t('ThanksTextSelfhosted') }}</p>
</v-alert>
<p class="text-h6 mt-2">{{ $t('Settings') }}</p>
<v-divider class="mb-2"></v-divider>
<v-text-field v-model="space.name" :label="$t('Name')"></v-text-field>
<user-file-field v-model="space.image" :label="$t('Image')" :hint="$t('CustomImageHelp')" persistent-hint></user-file-field>
<v-textarea v-model="space.message" :label="$t('Message')"></v-textarea>
<v-btn color="success" @click="updateSpace()" prepend-icon="$save">{{ $t('Save') }}</v-btn>
<p class="text-h6 mt-2">{{ $t('AI') }}</p>
<v-divider class="mb-2"></v-divider>
<p class="text-disabled font-italic text-body-2">
<span v-if="useUserPreferenceStore().serverSettings.hosted">
{{ $t('AISettingsHostedHelp') }}
</span>
<span v-else>
{{ $t('SettingsOnlySuperuser') }}
</span>
</p>
<v-checkbox v-model="space.aiEnabled" :label="$t('Enabled')" :disabled="!useUserPreferenceStore().userSettings.user.isSuperuser" hide-details></v-checkbox>
<template v-if="space.aiEnabled">
<model-select model="AiProvider" :label="$t('Default')" v-model="space.aiDefaultProvider"></model-select>
<v-number-input v-model="space.aiCreditsMonthly" :precision="2" :label="$t('MonthlyCredits')"
:disabled="!useUserPreferenceStore().userSettings.user.isSuperuser"></v-number-input>
<v-number-input v-model="space.aiCreditsBalance" :precision="4" :label="$t('AiCreditsBalance')"
:disabled="!useUserPreferenceStore().userSettings.user.isSuperuser"></v-number-input>
</template>
<v-btn color="success" @click="updateSpace()" prepend-icon="$save">{{ $t('Save') }}</v-btn>
<v-divider class="mt-4 mb-2"></v-divider>
<h2>{{ $t('Cosmetic') }}</h2>
<span>{{ $t('Space_Cosmetic_Settings') }}</span>
<v-label class="mt-4">{{ $t('Nav_Color') }}</v-label>
<v-color-picker v-model="space.navBgColor" class="mb-4" mode="hex" :modes="['hex']" show-swatches
:swatches="[['#ddbf86'],['#b98766'],['#b55e4f'],['#82aa8b'],['#385f84']]"></v-color-picker>
<v-btn class="mb-4" @click="space.navBgColor = ''">{{ $t('Reset') }}</v-btn>
<user-file-field v-model="space.navLogo" :label="$t('Logo')" :hint="$t('CustomNavLogoHelp')" persistent-hint></user-file-field>
<user-file-field v-model="space.logoColor32" :label="$t('Logo') + ' 32x32px'"></user-file-field>
<user-file-field v-model="space.logoColor128" :label="$t('Logo') + ' 128x128px'"></user-file-field>
<user-file-field v-model="space.logoColor144" :label="$t('Logo') + ' 144x144px'"></user-file-field>
<user-file-field v-model="space.logoColor180" :label="$t('Logo') + ' 180x180px'"></user-file-field>
<user-file-field v-model="space.logoColor192" :label="$t('Logo') + ' 192x192px'"></user-file-field>
<user-file-field v-model="space.logoColor512" :label="$t('Logo') + ' 512x512px'"></user-file-field>
<user-file-field v-model="space.logoColorSvg" :label="$t('Logo') + ' SVG'"></user-file-field>
<user-file-field v-model="space.customSpaceTheme" :label="$t('CustomTheme') + ' CSS'"></user-file-field>
<v-btn color="success" @click="updateSpace()" prepend-icon="$save">{{ $t('Save') }}</v-btn>
<space-editor :item-id="useUserPreferenceStore().activeSpace.id!"></space-editor>
</v-form>
</template>
<script setup lang="ts">
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import {onMounted, ref} from "vue";
import {ApiApi, Space} from "@/openapi";
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
import UserFileField from "@/components/inputs/UserFileField.vue";
import ModelSelect from "@/components/inputs/ModelSelect.vue";
import {isSpaceAboveRecipeLimit, isSpaceAboveStorageLimit, isSpaceAboveUserLimit} from "@/utils/logic_utils";
const space = ref({} as Space)
onMounted(() => {
loadSpace()
})
function loadSpace() {
let api = new ApiApi()
api.apiSpaceCurrentRetrieve().then(r => {
space.value = r
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
}
function updateSpace() {
let api = new ApiApi()
api.apiSpacePartialUpdate({id: space.value.id, patchedSpace: space.value}).then(r => {
space.value = r
useUserPreferenceStore().activeSpace = Object.assign({}, space.value)
useMessageStore().addPreparedMessage(PreparedMessage.UPDATE_SUCCESS, space.value)
}).catch(err => {
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
})
}
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
import SpaceLimitsInfo from "@/components/display/SpaceLimitsInfo.vue";
import SpaceEditor from "@/components/model_editors/SpaceEditor.vue";
</script>
<style scoped>

View File

@@ -1,66 +0,0 @@
<template>
<v-row>
<v-col>
<p class="text-h6">
{{ $t('YourSpaces') }}
<v-btn color="create" prepend-icon="$add" class="float-right" size="small" @click="createNewSpace()">{{ $t('New') }}</v-btn>
</p>
<v-divider></v-divider>
</v-col>
</v-row>
<v-row>
<v-col cols="6" v-for="s in spaces" :key="s.id">
<v-card @click="useUserPreferenceStore().switchSpace(s)">
<v-img height="200px" cover :src="(s.image !== undefined) ? s.image?.preview : recipeDefaultImage" :alt="$t('Image')"></v-img>
<v-card-title>{{ s.name }}
<v-chip variant="tonal" density="compact" color="error" v-if="s.id == useUserPreferenceStore().activeSpace.id">{{ $t('active') }}</v-chip>
</v-card-title>
<v-card-subtitle>{{ $t('created_by') }} {{ s.createdBy.displayName }}</v-card-subtitle>
</v-card>
</v-col>
</v-row>
</template>
<script setup lang="ts">
import {onMounted, ref} from "vue";
import {ApiApi, type FoodInheritField, Space} from "@/openapi";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
import recipeDefaultImage from '../../assets/recipe_no_image.svg'
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import {useDjangoUrls} from "@/composables/useDjangoUrls";
const {getDjangoUrl} = useDjangoUrls()
const spaces = ref([] as Space[])
onMounted(() => {
loadSpaces()
})
function loadSpaces() {
const api = new ApiApi()
api.apiSpaceList().then(r => {
spaces.value = r.results
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
}
function createNewSpace() {
let api = new ApiApi()
api.apiSpaceCreate({space: {} as Space}).then(r => {
spaces.value.push(r)
}).catch(err => {
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err)
})
}
</script>
<style scoped>
</style>

View File

@@ -288,6 +288,7 @@
"Show_as_header": "",
"Single": "",
"Size": "",
"Skip": "",
"Sort_by_new": "",
"Space": "",
"SpaceHelp": "",

View File

@@ -281,6 +281,7 @@
"Show_as_header": "Показване като заглавка",
"Single": "Единичен",
"Size": "Размер",
"Skip": "",
"Sort_by_new": "Сортиране по ново",
"Space": "",
"SpaceHelp": "",

View File

@@ -365,6 +365,7 @@
"Show_as_header": "Mostreu com a títol",
"Single": "Únic/a",
"Size": "Mida",
"Skip": "",
"Social_Authentication": "Identificació amb Xarxes Socials",
"Sort_by_new": "Ordenar a partir del més nou",
"Space": "",

View File

@@ -360,6 +360,7 @@
"Show_as_header": "Nastav jako nadpis",
"Single": "Jednoduchý",
"Size": "Velikost",
"Skip": "",
"Social_Authentication": "Přihlašování pomocí účtů sociálních sítí",
"Sort_by_new": "Seřadit od nejnovějšího",
"Space": "",

View File

@@ -365,6 +365,7 @@
"Show_as_header": "Vis som rubrik",
"Single": "Enkel",
"Size": "Størrelse",
"Skip": "",
"Social_Authentication": "Social authenticering",
"Sort_by_new": "Sorter efter nylige",
"Space": "",

View File

@@ -503,6 +503,7 @@
"Show_as_header": "Als Überschrift",
"Single": "Einzeln",
"Size": "Größe",
"Skip": "Überspringen",
"Social_Authentication": "Login über Drittanbieter",
"Sort_by_new": "Nach Neueste sortieren",
"Source": "Quelle",

View File

@@ -365,6 +365,7 @@
"Show_as_header": "Εμφάνιση ως κεφαλίδα",
"Single": "Ενικός",
"Size": "Μέγεθος",
"Skip": "",
"Social_Authentication": "Ταυτοποίηση μέσω κοινωνικών δικτύων",
"Sort_by_new": "Ταξινόμηση κατά νέο",
"Space": "",

View File

@@ -501,6 +501,7 @@
"Show_as_header": "Show as header",
"Single": "Single",
"Size": "Size",
"Skip": "Skip",
"Social_Authentication": "Social Authentication",
"Sort_by_new": "Sort by new",
"Source": "Source",

View File

@@ -484,6 +484,7 @@
"Show_as_header": "Mostrar como encabezado",
"Single": "Simple",
"Size": "Tamaño",
"Skip": "",
"Social_Authentication": "Autenticación Social",
"Sort_by_new": "Ordenar por novedades",
"SourceImportHelp": "Importar JSON en formato schema.org/recipe o páginas HTML con recetas en formato JSON+LD o microdatos.",

View File

@@ -353,6 +353,7 @@
"Show_as_header": "Näytä otsikkona",
"Single": "Yksittäinen",
"Size": "Koko",
"Skip": "",
"Social_Authentication": "Sosiaalinen Todennus",
"Sort_by_new": "Lajittele uusien mukaan",
"Space": "",

View File

@@ -498,6 +498,7 @@
"Show_as_header": "Montrer comme en-tête",
"Single": "Unique",
"Size": "Taille",
"Skip": "",
"Social_Authentication": "Authentification Sociale",
"Sort_by_new": "Trier par nouveautés",
"Source": "Source",

View File

@@ -365,6 +365,7 @@
"Show_as_header": "הצג בתור כותרת",
"Single": "בודד",
"Size": "גודל",
"Skip": "",
"Social_Authentication": "אימות חברתי",
"Sort_by_new": "סדר ע\"י חדש",
"Space": "",

View File

@@ -365,6 +365,7 @@
"Show_as_header": "Prikaži kao zaglavlje",
"Single": "Jedna",
"Size": "Veličina",
"Skip": "",
"Social_Authentication": "Autentifikacija putem društvenih mreža",
"Sort_by_new": "Poredaj po novom",
"Space": "",

View File

@@ -333,6 +333,7 @@
"Show_as_header": "Megjelenítés címként",
"Single": "Egyetlen",
"Size": "Méret",
"Skip": "",
"Sort_by_new": "Rendezés legújabbak szerint",
"Space": "",
"SpaceHelp": "",

View File

@@ -150,6 +150,7 @@
"Shopping_list": "Գնումների ցուցակ",
"Show_as_header": "Ցույց տալ որպես խորագիր",
"Size": "",
"Skip": "",
"Sort_by_new": "Տեսակավորել ըստ նորերի",
"Space": "",
"SpaceHelp": "",

View File

@@ -309,6 +309,7 @@
"Show_as_header": "Tampilkan sebagai tajuk",
"Single": "",
"Size": "Ukuran",
"Skip": "",
"Social_Authentication": "",
"Sort_by_new": "Urutkan berdasarkan baru",
"Space": "",

View File

@@ -363,6 +363,7 @@
"Show_as_header": "",
"Single": "",
"Size": "",
"Skip": "",
"Social_Authentication": "",
"Sort_by_new": "",
"Space": "",

View File

@@ -500,6 +500,7 @@
"Show_as_header": "Mostra come intestazione",
"Single": "Singolo",
"Size": "Dimensione",
"Skip": "",
"Social_Authentication": "Autenticazione social",
"Sort_by_new": "Prima i nuovi",
"Source": "Fonte",

View File

@@ -337,6 +337,7 @@
"Show_as_header": "Rodyti kaip antraštę",
"Single": "",
"Size": "",
"Skip": "",
"Social_Authentication": "",
"Sort_by_new": "Rūšiuoti pagal naujumą",
"Space": "",

View File

@@ -365,6 +365,7 @@
"Show_as_header": "",
"Single": "",
"Size": "",
"Skip": "",
"Social_Authentication": "",
"Sort_by_new": "",
"Space": "",

View File

@@ -347,6 +347,7 @@
"Show_as_header": "Vis som overskrift",
"Single": "",
"Size": "Størrelse",
"Skip": "",
"Social_Authentication": "",
"Sort_by_new": "Sorter etter nyest",
"Space": "",

View File

@@ -501,6 +501,7 @@
"Show_as_header": "Toon als koptekst",
"Single": "Enkele",
"Size": "Grootte",
"Skip": "",
"Social_Authentication": "Authenticeren met sociale media-account",
"Sort_by_new": "Sorteer op nieuw",
"Source": "Bron",

View File

@@ -391,6 +391,7 @@
"Show_as_header": "Pokaż jako nagłówek",
"Single": "Pojedynczy",
"Size": "Rozmiar",
"Skip": "",
"Social_Authentication": "Uwierzytelnianie społecznościowe",
"Sort_by_new": "Sortuj według nowych",
"Space": "",

View File

@@ -299,6 +299,7 @@
"Show_Week_Numbers": "Mostrar números das semanas?",
"Show_as_header": "Mostrar como cabeçalho",
"Size": "Tamanho",
"Skip": "",
"Sort_by_new": "Ordenar por mais recente",
"Space": "",
"SpaceHelp": "",

View File

@@ -439,6 +439,7 @@
"Show_as_header": "Mostrar como título",
"Single": "Simples",
"Size": "Tamanho",
"Skip": "",
"Social_Authentication": "Autenticação social",
"Sort_by_new": "Ordenar por novos",
"Space": "",

View File

@@ -321,6 +321,7 @@
"Show_as_header": "Afișare ca antet",
"Single": "Singur",
"Size": "Marime",
"Skip": "",
"Social_Authentication": "Autentificare socială",
"Sort_by_new": "Sortare după nou",
"Space": "",

View File

@@ -498,6 +498,7 @@
"Show_as_header": "Показывать как заголовок",
"Single": "Одиночный",
"Size": "Размер",
"Skip": "",
"Social_Authentication": "Социальная аутентификация",
"Sort_by_new": "Сортировка по новизне",
"Source": "Источник",

View File

@@ -500,6 +500,7 @@
"Show_as_header": "Prikaži kot glavo",
"Single": "Ena",
"Size": "Velikost",
"Skip": "",
"Social_Authentication": "Socialna avtentikacija",
"Sort_by_new": "Razvrsti po novih",
"Source": "Vir",

View File

@@ -402,6 +402,7 @@
"Show_as_header": "Visa som rubrik",
"Single": "Enstaka",
"Size": "Storlek",
"Skip": "",
"Social_Authentication": "Social autentisering",
"Sort_by_new": "Sortera efter ny",
"Space": "",

View File

@@ -365,6 +365,7 @@
"Show_as_header": "Başlık olarak göster",
"Single": "Tek",
"Size": "Boyut",
"Skip": "",
"Social_Authentication": "Sosyal Kimlik Doğrulama",
"Sort_by_new": "Yeniye göre sırala",
"Space": "",

View File

@@ -323,6 +323,7 @@
"Show_as_header": "Показати як заголовок",
"Single": "",
"Size": "Розмір",
"Skip": "",
"Sort_by_new": "Сортувати за новими",
"Space": "",
"SpaceHelp": "",

View File

@@ -365,6 +365,7 @@
"Show_as_header": "显示标题",
"Single": "单个",
"Size": "大小",
"Skip": "",
"Social_Authentication": "社交认证",
"Sort_by_new": "按新旧排序",
"Space": "",

View File

@@ -499,6 +499,7 @@
"Show_as_header": "顯示為標題",
"Single": "單一",
"Size": "大小",
"Skip": "",
"Social_Authentication": "社交認證",
"Sort_by_new": "按最新排序",
"Source": "來源",

View File

@@ -1803,6 +1803,11 @@ export interface ApiSpaceRetrieveRequest {
id: number;
}
export interface ApiSpaceUpdateRequest {
id: number;
space?: Omit<Space, 'createdBy'|'createdAt'|'maxRecipes'|'maxFileStorageMb'|'maxUsers'|'allowSharing'|'demo'|'userCount'|'recipeCount'|'fileSizeMb'|'aiMonthlyCreditsUsed'>;
}
export interface ApiStepCreateRequest {
step: Omit<Step, 'instructionsMarkdown'|'stepRecipeData'|'numrecipe'>;
}
@@ -13265,6 +13270,46 @@ export class ApiApi extends runtime.BaseAPI {
return await response.value();
}
/**
* logs request counts to redis cache total/per user/
*/
async apiSpaceUpdateRaw(requestParameters: ApiSpaceUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Space>> {
if (requestParameters['id'] == null) {
throw new runtime.RequiredError(
'id',
'Required parameter "id" was null or undefined when calling apiSpaceUpdate().'
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
headerParameters['Content-Type'] = 'application/json';
if (this.configuration && this.configuration.apiKey) {
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
}
const response = await this.request({
path: `/api/space/{id}/`.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters['id']))),
method: 'PUT',
headers: headerParameters,
query: queryParameters,
body: SpaceToJSON(requestParameters['space']),
}, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) => SpaceFromJSON(jsonValue));
}
/**
* logs request counts to redis cache total/per user/
*/
async apiSpaceUpdate(requestParameters: ApiSpaceUpdateRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Space> {
const response = await this.apiSpaceUpdateRaw(requestParameters, initOverrides);
return await response.value();
}
/**
* logs request counts to redis cache total/per user/
*/

View File

@@ -13,9 +13,7 @@
<v-list-item :to="{name: 'SearchSettings'}" prepend-icon="$search">{{ $t('Search') }}</v-list-item>
<v-divider></v-divider>
<v-list-subheader>Space</v-list-subheader>
<v-list-item :to="{name: 'UserSpaceSettings'}" prepend-icon="$spaces">{{ $t('YourSpaces') }}</v-list-item>
<v-list-item :to="{name: 'SpaceSettings'}" prepend-icon="$settings">{{ $t('SpaceSettings') }}</v-list-item>
<v-list-item :to="{name: 'SpaceMemberSettings'}" prepend-icon="fa-solid fa-users">{{ $t('SpaceMembers') }}</v-list-item>
<v-list-item :to="{name: 'OpenDataImportSettings'}" prepend-icon="fa-solid fa-cloud-arrow-down">{{ $t('Open_Data_Import') }}</v-list-item>
<v-list-item :to="{name: 'ExportDataSettings'}" prepend-icon="fa-solid fa-file-export">{{ $t('Export') }}</v-list-item>
<v-divider></v-divider>

View File

@@ -39,6 +39,7 @@
<v-spacer></v-spacer>
</template>
<template #next>
<v-btn @click="finishWelcome()" color="warning" class="me-2" :loading="loading">{{ $t('Skip') }}</v-btn>
<v-btn @click="updateSpaceAndUserSettings()" :loading="loading" color="success">{{ $t('Next') }}</v-btn>
</template>
</v-stepper-actions>
@@ -194,8 +195,10 @@ onMounted(() => {
function finishWelcome(target: RouteLocationRaw = {name: 'StartPage'}) {
if (space.value) {
space.value.spaceSetupCompleted = true
loading.value = true
updateSpace().then(() => {
router.push(target)
loading.value = false
})
} else {
useMessageStore().addMessage(MessageType.ERROR, "Space not loaded yet", 5000)