refactor: use vuetify form controls

This commit is contained in:
2026-05-07 19:38:51 -04:00
parent 6ac05e1a10
commit 4aaa1a7f90
21 changed files with 724 additions and 774 deletions

View File

@@ -0,0 +1,26 @@
# Task: Replace native form controls with Vuetify controls
## Goal
Move interactive form fields from native `input`, `select`, and `textarea` elements to Vuetify form components so form theming flows through `createVuetify`.
## Scope
- Replace native text, email, URL, search, date, and number inputs with `v-text-field`.
- Replace native selects with `v-select`.
- Replace native textareas with `v-textarea`.
- Replace native checkboxes/radios with Vuetify selection controls where practical.
- Preserve file inputs where Vuetify would reduce custom upload behavior.
- Keep custom navigation and row action buttons out of this pass unless they are part of a form.
## Validation
```bash
cd frontend
npm run build
```
## Done
- [x] Native form controls under `frontend/src/**/*.vue` were replaced with Vuetify form components.
- [x] Frontend build passes.

View File

@@ -44,7 +44,6 @@
const emit = defineEmits(['update:modelValue', 'save']);
const fileInput = ref(null);
const cropper = ref(null);
const imageUrl = ref(null);
const remoteUrl = ref('');
@@ -67,17 +66,11 @@
imageUrl.value = props.initialUrl || null;
remoteUrl.value = props.initialUrl || '';
error.value = null;
if (fileInput.value) {
fileInput.value.value = '';
}
}
function chooseImage() {
fileInput.value?.click();
}
function onFileSelected(value) {
const file = Array.isArray(value) ? value[0] : value;
function onFileSelected(event) {
const [file] = event.target.files ?? [];
if (!file) {
return;
}
@@ -165,28 +158,25 @@
</div>
<div class="cropper-actions">
<input
ref="fileInput"
type="file"
<v-file-input
:label="uploadLabel"
accept="image/*"
class="hidden-input"
@change="onFileSelected"
/>
<button
class="action-button"
:disabled="isSaving"
@click="chooseImage"
>
{{ uploadLabel }}
</button>
density="compact"
variant="outlined"
hide-details
@update:model-value="onFileSelected"
/>
<div class="url-controls">
<input
<v-text-field
v-model="remoteUrl"
type="url"
class="url-input"
:placeholder="sourceLabel"
:disabled="isSaving"
density="compact"
variant="outlined"
hide-details
/>
<button
class="action-button secondary"

View File

@@ -10,36 +10,25 @@
</p>
<div class="card">
<form @submit.prevent="handleForgotPassword">
<v-form @submit.prevent="handleForgotPassword">
<div class="card-content">
<div class="flex flex-col gap-4">
<div class="form-field">
<label
class="form-label"
for="email"
>
{{ t('email') }}
</label>
<input
id="email"
v-model="email"
class="form-input"
required
type="email"
/>
</div>
<v-text-field
v-model="email"
:label="t('email')"
required
type="email"
variant="outlined"
/>
<button
:disabled="isLoading"
class="primary w-full"
<v-btn
:loading="isLoading"
block
color="primary"
type="submit"
>
<span
v-if="isLoading"
class="loading-spinner mr-2"
></span>
{{ t('resetPassword') }}
</button>
</v-btn>
<div class="text-center mt-4">
<router-link
@@ -51,7 +40,7 @@
</div>
</div>
</div>
</form>
</v-form>
</div>
<!-- Success message -->

View File

@@ -5,68 +5,34 @@
{{ t('title') }}
</h1>
<form
<v-form
class="card"
@submit.prevent="handleResetPassword"
>
<div class="card-content">
<div class="flex flex-col gap-4">
<div class="form-field">
<label
class="form-label"
for="password"
>
{{ t('newPassword') }}
</label>
<div class="relative">
<input
id="password"
v-model="password"
:type="showPassword ? 'text' : 'password'"
class="form-input"
required
/>
<button
class="password-toggle"
type="button"
@click="showPassword = !showPassword"
>
<v-icon
:icon="showPassword ? mdiEyeOff : mdiEye"
size="small"
/>
</button>
</div>
<div>
<v-text-field
v-model="password"
:append-inner-icon="showPassword ? mdiEyeOff : mdiEye"
:label="t('newPassword')"
:type="showPassword ? 'text' : 'password'"
required
variant="outlined"
@click:append-inner="showPassword = !showPassword"
/>
<p class="mt-1 text-sm text-gray-500">{{ t('passwordRequirements') }}</p>
</div>
<div class="form-field">
<label
class="form-label"
for="confirmPassword"
>
{{ t('confirmPassword') }}
</label>
<div class="relative">
<input
id="confirmPassword"
v-model="confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
class="form-input"
required
/>
<button
class="password-toggle"
type="button"
@click="showConfirmPassword = !showConfirmPassword"
>
<v-icon
:icon="showConfirmPassword ? mdiEyeOff : mdiEye"
size="small"
/>
</button>
</div>
</div>
<v-text-field
v-model="confirmPassword"
:append-inner-icon="showConfirmPassword ? mdiEyeOff : mdiEye"
:label="t('confirmPassword')"
:type="showConfirmPassword ? 'text' : 'password'"
required
variant="outlined"
@click:append-inner="showConfirmPassword = !showConfirmPassword"
/>
<div
v-if="errorMessage"
@@ -75,20 +41,17 @@
{{ errorMessage }}
</div>
<button
:disabled="isLoading"
class="primary w-full"
<v-btn
:loading="isLoading"
block
color="primary"
type="submit"
>
<span
v-if="isLoading"
class="loading-spinner mr-2"
></span>
{{ t('resetPassword') }}
</button>
</v-btn>
</div>
</div>
</form>
</v-form>
<!-- Success message -->
<div

View File

@@ -129,48 +129,52 @@
</div>
<div class="form-grid">
<label class="field">
<span>{{ t('campaigns.fields.startDate') }}</span>
<input
v-model="form.startDate"
type="date"
:disabled="campaignsStore.isCreating"
/>
</label>
<v-text-field
v-model="form.startDate"
:label="t('campaigns.fields.startDate')"
:disabled="campaignsStore.isCreating"
type="date"
variant="outlined"
hide-details
/>
<label class="field">
<span>{{ t('campaigns.fields.endDate') }}</span>
<input
v-model="form.endDate"
type="date"
:disabled="campaignsStore.isCreating"
/>
</label>
<v-text-field
v-model="form.endDate"
:label="t('campaigns.fields.endDate')"
:disabled="campaignsStore.isCreating"
type="date"
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>{{ t('campaigns.fields.name') }}</span>
<input
v-model="form.name"
type="text"
:disabled="campaignsStore.isCreating"
/>
</label>
<v-text-field
v-model="form.name"
class="field-wide"
:label="t('campaigns.fields.name')"
:disabled="campaignsStore.isCreating"
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>{{ t('campaigns.fields.description') }}</span>
<textarea
v-model="form.description"
:disabled="campaignsStore.isCreating"
></textarea>
</label>
<v-textarea
v-model="form.description"
class="field-wide"
:label="t('campaigns.fields.description')"
:disabled="campaignsStore.isCreating"
rows="3"
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>{{ t('campaigns.fields.notes') }}</span>
<textarea
v-model="form.notes"
:disabled="campaignsStore.isCreating"
></textarea>
</label>
<v-textarea
v-model="form.notes"
class="field-wide"
:label="t('campaigns.fields.notes')"
:disabled="campaignsStore.isCreating"
rows="3"
variant="outlined"
hide-details
/>
</div>
<div class="panel-actions">

View File

@@ -164,13 +164,12 @@
</div>
<div class="form-grid">
<label class="field">
<span>{{ t('channels.fields.name') }}</span>
<input
v-model="form.name"
type="text"
/>
</label>
<v-text-field
v-model="form.name"
:label="t('channels.fields.name')"
variant="outlined"
hide-details
/>
</div>
<div class="panel-actions">

View File

@@ -280,26 +280,23 @@
</div>
<div class="form-grid">
<label class="field field-wide">
<span>Client name</span>
<input
v-model="form.name"
type="text"
:disabled="clientsStore.isUpdating"
/>
</label>
<v-text-field
v-model="form.name"
class="field-wide"
label="Client name"
:disabled="clientsStore.isUpdating"
variant="outlined"
hide-details
/>
<label class="field">
<span>Status</span>
<select
v-model="form.status"
:disabled="clientsStore.isUpdating"
>
<option value="Active">Active</option>
<option value="Paused">Paused</option>
<option value="Archived">Archived</option>
</select>
</label>
<v-select
v-model="form.status"
:items="['Active', 'Paused', 'Archived']"
label="Status"
:disabled="clientsStore.isUpdating"
variant="outlined"
hide-details
/>
<div class="field field-wide image-field">
<span>Client logo</span>
@@ -332,23 +329,22 @@
</div>
</div>
<label class="field">
<span>Primary contact name</span>
<input
v-model="form.primaryContactName"
type="text"
:disabled="clientsStore.isUpdating"
/>
</label>
<v-text-field
v-model="form.primaryContactName"
label="Primary contact name"
:disabled="clientsStore.isUpdating"
variant="outlined"
hide-details
/>
<label class="field">
<span>Primary contact email</span>
<input
v-model="form.primaryContactEmail"
type="email"
:disabled="clientsStore.isUpdating"
/>
</label>
<v-text-field
v-model="form.primaryContactEmail"
label="Primary contact email"
:disabled="clientsStore.isUpdating"
type="email"
variant="outlined"
hide-details
/>
<div class="field field-wide image-field">
<span>Primary contact portrait</span>

View File

@@ -101,52 +101,53 @@
</div>
<div class="form-grid">
<label class="field field-wide">
<span>{{ t('clients.fields.name') }}</span>
<input
v-model="form.name"
type="text"
:disabled="clientsStore.isCreating"
/>
</label>
<v-text-field
v-model="form.name"
class="field-wide"
:label="t('clients.fields.name')"
:disabled="clientsStore.isCreating"
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>{{ t('clients.fields.portraitUrl') }}</span>
<input
v-model="form.portraitUrl"
type="url"
placeholder="https://..."
:disabled="clientsStore.isCreating"
/>
</label>
<v-text-field
v-model="form.portraitUrl"
class="field-wide"
:label="t('clients.fields.portraitUrl')"
:disabled="clientsStore.isCreating"
placeholder="https://..."
type="url"
variant="outlined"
hide-details
/>
<label class="field">
<span>{{ t('clients.fields.primaryContactName') }}</span>
<input
v-model="form.primaryContactName"
type="text"
:disabled="clientsStore.isCreating"
/>
</label>
<v-text-field
v-model="form.primaryContactName"
:label="t('clients.fields.primaryContactName')"
:disabled="clientsStore.isCreating"
variant="outlined"
hide-details
/>
<label class="field">
<span>{{ t('clients.fields.primaryContactEmail') }}</span>
<input
v-model="form.primaryContactEmail"
type="email"
:disabled="clientsStore.isCreating"
/>
</label>
<v-text-field
v-model="form.primaryContactEmail"
:label="t('clients.fields.primaryContactEmail')"
:disabled="clientsStore.isCreating"
type="email"
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>{{ t('clients.fields.primaryContactPortraitUrl') }}</span>
<input
v-model="form.primaryContactPortraitUrl"
type="url"
placeholder="https://..."
:disabled="clientsStore.isCreating"
/>
</label>
<v-text-field
v-model="form.primaryContactPortraitUrl"
class="field-wide"
:label="t('clients.fields.primaryContactPortraitUrl')"
:disabled="clientsStore.isCreating"
placeholder="https://..."
type="url"
variant="outlined"
hide-details
/>
</div>
<div class="panel-actions">

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { computed, reactive } from 'vue';
import { mdiAt, mdiClose, mdiImagePlusOutline, mdiLockOutline, mdiSend } from '@mdi/js';
import AppAvatar from '@/components/AppAvatar.vue';
import { useUserProfileStore } from '@/features/user-profile/stores/userProfileStore.js';
@@ -25,7 +25,6 @@
const emit = defineEmits(['submit-comment', 'cancel-reply']);
const userProfileStore = useUserProfileStore();
const mediaFileInput = ref(null);
const form = reactive({
body: '',
@@ -73,25 +72,15 @@
form.isInternal = false;
form.mediaFile = null;
form.showMentionPicker = false;
if (mediaFileInput.value) {
mediaFileInput.value.value = '';
}
}
function openMediaPicker() {
mediaFileInput.value?.click();
function selectMediaFile(file) {
form.mediaFile = Array.isArray(file) ? file[0] ?? null : file;
form.showMentionPicker = false;
}
function selectMediaFile(event) {
form.mediaFile = event.target.files?.[0] ?? null;
}
function clearMediaFile() {
form.mediaFile = null;
if (mediaFileInput.value) {
mediaFileInput.value.value = '';
}
}
function toggleMentionPicker() {
@@ -143,11 +132,15 @@
size="md"
/>
<textarea
<v-textarea
v-model="form.body"
class="comment-textarea"
:placeholder="replyTarget ? 'Write a reply...' : 'Write a comment...'"
></textarea>
variant="outlined"
hide-details
auto-grow
rows="3"
/>
</div>
<div
@@ -193,32 +186,27 @@
<div class="comment-composer-toolbar">
<div class="comment-tool-actions">
<label
<div
class="icon-tool-button internal-toggle"
:class="{ active: form.isInternal }"
title="Internal comment"
>
<input
<v-checkbox-btn
v-model="form.isInternal"
type="checkbox"
density="compact"
/>
<v-icon :icon="mdiLockOutline" />
</label>
<button
class="icon-tool-button"
type="button"
</div>
<v-file-input
v-model="form.mediaFile"
class="media-file-control"
title="Upload media from computer"
:class="{ active: form.mediaFile }"
@click="openMediaPicker"
>
<v-icon :icon="mdiImagePlusOutline" />
</button>
<input
ref="mediaFileInput"
class="sr-only"
type="file"
accept="image/png,image/jpeg,image/jpg"
@change="selectMediaFile"
:prepend-icon="mdiImagePlusOutline"
density="compact"
variant="outlined"
hide-details
@update:model-value="selectMediaFile"
/>
<button
class="icon-tool-button"

View File

@@ -787,40 +787,31 @@
</div>
<div class="form-grid">
<label class="field">
<span>Title</span>
<input
v-model="form.title"
type="text"
/>
</label>
<label class="field">
<span>Campaign</span>
<select v-model="form.campaignId">
<option
disabled
value=""
>
Select a campaign
</option>
<option
v-for="campaign in availableCampaigns"
:key="campaign.id"
:value="campaign.id"
>
{{ campaign.name }}
</option>
</select>
</label>
<label class="field">
<span>Due date</span>
<input
v-model="form.dueDate"
type="date"
<v-text-field
v-model="form.title"
label="Title"
variant="outlined"
hide-details
/>
</label>
<v-select
v-model="form.campaignId"
:items="availableCampaigns"
label="Campaign"
item-title="name"
item-value="id"
placeholder="Select a campaign"
variant="outlined"
hide-details
/>
<v-text-field
v-model="form.dueDate"
label="Due date"
type="date"
variant="outlined"
hide-details
/>
<div class="date-context field-wide">
<div class="date-context-days">
@@ -864,19 +855,23 @@
</div>
</div>
<label class="field field-wide">
<span>Change summary</span>
<input
v-model="form.changeSummary"
type="text"
placeholder="What changed in this revision?"
/>
</label>
<v-text-field
v-model="form.changeSummary"
class="field-wide"
label="Change summary"
placeholder="What changed in this revision?"
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>Shared brief / base caption</span>
<textarea v-model="form.body"></textarea>
</label>
<v-textarea
v-model="form.body"
class="field-wide"
label="Shared brief / base caption"
rows="4"
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>Shared hashtags</span>
@@ -899,11 +894,13 @@
{{ tag.startsWith('#') ? tag : `#${tag}` }}
</v-chip>
</div>
<input
<v-text-field
v-model="form.hashtags"
type="text"
class="hashtags-inline-input"
placeholder="#launch #campaign #brand"
density="compact"
variant="plain"
hide-details
/>
</div>
</label>
@@ -961,63 +958,61 @@
</div>
<div class="form-grid compact-grid">
<label class="field">
<span>Network</span>
<input
v-model="placement.network"
type="text"
placeholder="Instagram, YouTube..."
/>
</label>
<v-text-field
v-model="placement.network"
label="Network"
placeholder="Instagram, YouTube..."
variant="outlined"
hide-details
/>
<label class="field">
<span>Channel</span>
<select
v-model="placement.channelId"
@change="syncPlacementChannel(placement, placement.channelId)"
>
<option value="">Select a configured channel</option>
<option
v-for="channel in availableChannels"
:key="channel.id"
:value="channel.id"
>
{{ channel.name }}{{ channel.network ? ` · ${channel.network}` : '' }}
</option>
</select>
</label>
<v-select
v-model="placement.channelId"
:items="availableChannels.map(channel => ({
title: `${channel.name}${channel.network ? ` · ${channel.network}` : ''}`,
value: channel.id,
}))"
label="Channel"
placeholder="Select a configured channel"
variant="outlined"
hide-details
clearable
@update:model-value="syncPlacementChannel(placement, $event)"
/>
<label class="field">
<span>Channel name</span>
<input
v-model="placement.channelName"
type="text"
placeholder="IG Feed, YouTube Main..."
/>
</label>
<v-text-field
v-model="placement.channelName"
label="Channel name"
placeholder="IG Feed, YouTube Main..."
variant="outlined"
hide-details
/>
<label class="field">
<span>Variant label</span>
<input
v-model="placement.variantLabel"
type="text"
placeholder="Reel version, Shorts version..."
/>
</label>
<v-text-field
v-model="placement.variantLabel"
label="Variant label"
placeholder="Reel version, Shorts version..."
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>Channel-specific caption</span>
<textarea v-model="placement.message"></textarea>
</label>
<v-textarea
v-model="placement.message"
class="field-wide"
label="Channel-specific caption"
rows="3"
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>Channel-specific hashtags</span>
<input
v-model="placement.hashtags"
type="text"
placeholder="#product #launch"
/>
</label>
<v-text-field
v-model="placement.hashtags"
class="field-wide"
label="Channel-specific hashtags"
placeholder="#product #launch"
variant="outlined"
hide-details
/>
</div>
<div class="media-section">
@@ -1042,32 +1037,30 @@
class="media-card"
>
<div class="form-grid compact-grid">
<label class="field">
<span>Media type</span>
<select v-model="media.mediaType">
<option value="Image">Image</option>
<option value="Video">Video</option>
<option value="Document">Document</option>
</select>
</label>
<v-select
v-model="media.mediaType"
:items="['Image', 'Video', 'Document']"
label="Media type"
variant="outlined"
hide-details
/>
<label class="field">
<span>Label</span>
<input
v-model="media.label"
type="text"
placeholder="Cover image, YouTube video..."
/>
</label>
<v-text-field
v-model="media.label"
label="Label"
placeholder="Cover image, YouTube video..."
variant="outlined"
hide-details
/>
<label class="field field-wide">
<span>Media URL / reference</span>
<input
v-model="media.url"
type="text"
placeholder="Google Drive link or asset URL"
/>
</label>
<v-text-field
v-model="media.url"
class="field-wide"
label="Media URL / reference"
placeholder="Google Drive link or asset URL"
variant="outlined"
hide-details
/>
</div>
<button
@@ -1170,46 +1163,43 @@
<template v-else-if="activeProductionTab === 'assets'">
<div class="panel-stack asset-form">
<label class="field">
<span>Type</span>
<select v-model="assetForm.assetType">
<option value="Image">Image</option>
<option value="Video">Video</option>
<option value="Document">Document</option>
<option value="Other">Other</option>
</select>
</label>
<label class="field">
<span>Name</span>
<input
v-model="assetForm.displayName"
type="text"
placeholder="Final reel, cover image..."
/>
</label>
<label class="field field-wide">
<span>Google Drive link</span>
<input
v-model="assetForm.googleDriveLink"
type="url"
placeholder="https://drive.google.com/..."
/>
</label>
<label class="field">
<span>File id</span>
<input
v-model="assetForm.googleDriveFileId"
type="text"
placeholder="Optional if link includes it"
/>
</label>
<label class="field">
<span>Preview URL</span>
<input
v-model="assetForm.previewUrl"
type="url"
/>
</label>
<v-select
v-model="assetForm.assetType"
:items="['Image', 'Video', 'Document', 'Other']"
label="Type"
variant="outlined"
hide-details
/>
<v-text-field
v-model="assetForm.displayName"
label="Name"
placeholder="Final reel, cover image..."
variant="outlined"
hide-details
/>
<v-text-field
v-model="assetForm.googleDriveLink"
class="field-wide"
label="Google Drive link"
type="url"
placeholder="https://drive.google.com/..."
variant="outlined"
hide-details
/>
<v-text-field
v-model="assetForm.googleDriveFileId"
label="File id"
placeholder="Optional if link includes it"
variant="outlined"
hide-details
/>
<v-text-field
v-model="assetForm.previewUrl"
label="Preview URL"
type="url"
variant="outlined"
hide-details
/>
<button
class="primary-button field-wide"
:disabled="detailStore.actions.asset"
@@ -1255,22 +1245,23 @@
</div>
<div class="panel-stack compact-form">
<label class="field field-wide">
<span>New revision reference</span>
<input
v-model="assetRevisionForm(asset.id).sourceReference"
type="url"
placeholder="Updated Drive link or production reference"
/>
</label>
<label class="field field-wide">
<span>Notes</span>
<input
v-model="assetRevisionForm(asset.id).notes"
type="text"
placeholder="What changed?"
/>
</label>
<v-text-field
v-model="assetRevisionForm(asset.id).sourceReference"
class="field-wide"
label="New revision reference"
type="url"
placeholder="Updated Drive link or production reference"
variant="outlined"
hide-details
/>
<v-text-field
v-model="assetRevisionForm(asset.id).notes"
class="field-wide"
label="Notes"
placeholder="What changed?"
variant="outlined"
hide-details
/>
<button
class="secondary-button"
:disabled="detailStore.actions.assetRevision"

View File

@@ -1302,42 +1302,48 @@
</button>
</div>
<div class="scope-row">
<label
<v-radio-group
v-model="customCalendarForm.scope"
class="scope-row"
inline
hide-details
>
<v-radio
v-for="option in addScopeOptions"
:key="option.value"
class="scope-option"
>
<input
v-model="customCalendarForm.scope"
type="radio"
:value="option.value"
>
<span>{{ option.label }}</span>
</label>
</div>
:label="option.label"
:value="option.value"
/>
</v-radio-group>
<div
v-if="activeAddMode === 'catalog'"
class="catalog-panel"
>
<div class="catalog-search">
<input
<v-text-field
v-model="catalogFilters.search"
density="compact"
variant="outlined"
hide-details
type="search"
:placeholder="t('contentItems.calendar.searchCatalog')"
>
<input
/>
<v-text-field
v-model="catalogFilters.country"
type="text"
density="compact"
variant="outlined"
hide-details
maxlength="2"
:placeholder="t('contentItems.calendar.country')"
>
<input
/>
<v-text-field
v-model="catalogFilters.category"
type="text"
density="compact"
variant="outlined"
hide-details
:placeholder="t('contentItems.calendar.category')"
>
/>
<button
class="text-button"
type="button"
@@ -1372,31 +1378,41 @@
</div>
</div>
<form
<v-form
v-else
class="custom-calendar-form"
@submit.prevent="addCustomSource"
>
<input
<v-text-field
v-model="customCalendarForm.title"
type="text"
density="compact"
variant="outlined"
hide-details
:placeholder="t('contentItems.calendar.calendarName')"
>
<input
/>
<v-text-field
v-model="customCalendarForm.sourceUrl"
type="url"
density="compact"
variant="outlined"
hide-details
:placeholder="t('contentItems.calendar.icsUrl')"
>
/>
<div class="custom-form-row">
<input
<v-text-field
v-model="customCalendarForm.color"
type="color"
>
<input
density="compact"
variant="outlined"
hide-details
/>
<v-text-field
v-model="customCalendarForm.category"
type="text"
density="compact"
variant="outlined"
hide-details
:placeholder="t('contentItems.calendar.category')"
>
/>
<button
class="text-button"
type="submit"
@@ -1405,7 +1421,7 @@
<span>{{ t('contentItems.calendar.addCalendar') }}</span>
</button>
</div>
</form>
</v-form>
<p
v-if="addCalendarError || calendarStore.error"

View File

@@ -317,7 +317,7 @@
</div>
</div>
<form
<v-form
class="comment-form"
@submit.prevent="submitComment"
>
@@ -336,7 +336,7 @@
>
{{ feedbackStore.isCommenting ? t('feedback.review.detail.commenting') : t('feedback.review.detail.addComment') }}
</button>
</form>
</v-form>
</section>
</main>

View File

@@ -100,14 +100,16 @@
</section>
<section class="filter-panel">
<label class="filter-search">
<v-icon :icon="mdiMagnify" />
<input
v-model="feedbackStore.filters.search"
type="search"
:placeholder="t('feedback.review.filters.search')"
/>
</label>
<v-text-field
v-model="feedbackStore.filters.search"
:label="t('feedback.review.filters.search')"
:prepend-inner-icon="mdiMagnify"
density="compact"
variant="outlined"
hide-details
clearable
type="search"
/>
<v-select
v-model="feedbackStore.filters.type"
@@ -139,32 +141,40 @@
clearable
/>
<input
<v-text-field
v-model="feedbackStore.filters.reporter"
class="field"
type="text"
:placeholder="t('feedback.review.filters.reporter')"
:label="t('feedback.review.filters.reporter')"
density="compact"
variant="outlined"
hide-details
clearable
/>
<input
<v-text-field
v-model="feedbackStore.filters.workspace"
class="field"
type="text"
:placeholder="t('feedback.review.filters.workspace')"
:label="t('feedback.review.filters.workspace')"
density="compact"
variant="outlined"
hide-details
clearable
/>
<input
<v-text-field
v-model="feedbackStore.filters.fromDate"
class="field"
:label="t('feedback.review.filters.fromDate')"
density="compact"
variant="outlined"
hide-details
type="date"
:aria-label="t('feedback.review.filters.fromDate')"
/>
<input
<v-text-field
v-model="feedbackStore.filters.toDate"
class="field"
:label="t('feedback.review.filters.toDate')"
density="compact"
variant="outlined"
hide-details
type="date"
:aria-label="t('feedback.review.filters.toDate')"
/>
<v-select

View File

@@ -207,18 +207,20 @@
size="lg"
/>
</button>
<form
<v-form
v-if="organization && isEditingName"
class="title-edit-form"
@submit.prevent="submitProfile"
>
<input
<v-text-field
v-model="profileForm.name"
type="text"
maxlength="256"
autocomplete="organization"
:aria-label="t('organizationSettings.fields.name')"
>
autocomplete="organization"
density="compact"
hide-details
maxlength="256"
variant="outlined"
/>
<button
class="icon-action"
type="submit"
@@ -238,7 +240,7 @@
>
<v-icon :icon="mdiClose" />
</button>
</form>
</v-form>
<div
v-else
class="title-row"
@@ -335,31 +337,26 @@
v-if="activeSection.key === 'members'"
class="table-list"
>
<form
<v-form
class="settings-form invite-form"
@submit.prevent="submitMember"
>
<label>
<span>{{ t('organizationSettings.fields.memberEmail') }}</span>
<input
v-model="memberForm.email"
type="email"
maxlength="256"
autocomplete="email"
>
</label>
<label>
<span>{{ t('organizationSettings.fields.memberRole') }}</span>
<select v-model="memberForm.role">
<option
v-for="role in memberRoleOptions"
:key="role"
:value="role"
>
{{ t(`organizationSettings.roles.${role}`, role) }}
</option>
</select>
</label>
<v-text-field
v-model="memberForm.email"
:label="t('organizationSettings.fields.memberEmail')"
autocomplete="email"
maxlength="256"
type="email"
variant="outlined"
hide-details
/>
<v-select
v-model="memberForm.role"
:items="memberRoleOptions.map(role => ({ title: t(`organizationSettings.roles.${role}`, role), value: role }))"
:label="t('organizationSettings.fields.memberRole')"
variant="outlined"
hide-details
/>
<div class="form-actions">
<button
class="primary-action"
@@ -369,7 +366,7 @@
{{ organizationStore.isAddingMember ? t('organizationSettings.addingMember') : t('organizationSettings.addMember') }}
</button>
</div>
</form>
</v-form>
<div
v-for="member in organization.members"
:key="member.userId"

View File

@@ -206,51 +206,48 @@
{{ settingsStatus }}
</div>
<form
<v-form
class="form-stack"
@submit.prevent="submitSettings"
>
<div class="details-grid">
<label class="field">
<span>{{ t('userSettings.firstname') }}</span>
<input
v-model="form.firstname"
type="text"
autocomplete="given-name"
:disabled="userProfileStore.isUpdating"
/>
</label>
<v-text-field
v-model="form.firstname"
:label="t('userSettings.firstname')"
autocomplete="given-name"
:disabled="userProfileStore.isUpdating"
variant="outlined"
hide-details
/>
<label class="field">
<span>{{ t('userSettings.lastname') }}</span>
<input
v-model="form.lastname"
type="text"
autocomplete="family-name"
:disabled="userProfileStore.isUpdating"
/>
</label>
<v-text-field
v-model="form.lastname"
:label="t('userSettings.lastname')"
autocomplete="family-name"
:disabled="userProfileStore.isUpdating"
variant="outlined"
hide-details
/>
<label class="field">
<span>{{ t('userSettings.alias') }}</span>
<input
v-model="form.alias"
type="text"
autocomplete="nickname"
:placeholder="fullname"
:disabled="userProfileStore.isUpdating"
/>
</label>
<v-text-field
v-model="form.alias"
:label="t('userSettings.alias')"
autocomplete="nickname"
:placeholder="fullname"
:disabled="userProfileStore.isUpdating"
variant="outlined"
hide-details
/>
<label class="field">
<span>{{ t('userSettings.email') }}</span>
<input
v-model="form.email"
type="email"
autocomplete="email"
:disabled="userProfileStore.isUpdating"
/>
</label>
<v-text-field
v-model="form.email"
:label="t('userSettings.email')"
autocomplete="email"
:disabled="userProfileStore.isUpdating"
type="email"
variant="outlined"
hide-details
/>
</div>
<div class="form-actions">
@@ -262,7 +259,7 @@
{{ userProfileStore.isUpdating ? t('common.saving') : t('userSettings.saveDetails') }}
</button>
</div>
</form>
</v-form>
</div>
<div class="panel">

View File

@@ -1,4 +1,5 @@
<script setup>
import { computed } from 'vue';
import {
mdiArrowDown,
mdiArrowUp,
@@ -40,6 +41,22 @@
];
const membershipOptions = ['Team', 'Client'];
const targetTypes = ['Role', 'Membership', 'Member'];
const roleItems = computed(() => roleOptions.map(role => ({
title: props.labels.roles[role],
value: role,
})));
const membershipItems = computed(() => membershipOptions.map(membership => ({
title: props.labels.memberships[membership],
value: membership,
})));
const targetTypeItems = computed(() => targetTypes.map(targetType => ({
title: props.labels.targetTypes[targetType],
value: targetType,
})));
const memberItems = computed(() => props.members.map(member => ({
title: `${member.displayName} - ${member.email}`,
value: member.id,
})));
function emitSteps(steps) {
emit('update:modelValue', steps.map((step, index) => ({
@@ -101,13 +118,8 @@
.filter(Boolean);
}
function updateMemberTargets(index, selectedOptions) {
const targetValue = Array.from(selectedOptions)
.map(option => option.value)
.filter(Boolean)
.join(',');
updateStep(index, { targetValue });
function updateMemberTargets(index, selectedMemberIds) {
updateStep(index, { targetValue: selectedMemberIds.filter(Boolean).join(',') });
}
function moveStep(index, offset) {
@@ -198,13 +210,14 @@
</div>
<div class="approval-step-fields">
<label class="field">
<span>{{ labels.fields.name }}</span>
<input
:value="step.name"
type="text"
<div class="field">
<v-text-field
:model-value="step.name"
:label="labels.fields.name"
:disabled="disabled"
@input="updateStep(index, { name: $event.target.value })"
variant="outlined"
hide-details
@update:model-value="updateStep(index, { name: $event })"
/>
<small
v-if="errors[index]?.name"
@@ -212,73 +225,54 @@
>
{{ errors[index].name }}
</small>
</label>
</div>
<label class="field">
<span>{{ labels.fields.targetType }}</span>
<select
:value="step.targetType"
:disabled="disabled"
@change="updateStep(index, { targetType: $event.target.value })"
>
<option
v-for="targetType in targetTypes"
:key="targetType"
:value="targetType"
>
{{ labels.targetTypes[targetType] }}
</option>
</select>
</label>
<v-select
:model-value="step.targetType"
:items="targetTypeItems"
:label="labels.fields.targetType"
:disabled="disabled"
variant="outlined"
hide-details
@update:model-value="updateStep(index, { targetType: $event })"
/>
<label class="field">
<span>{{ labels.fields.targetValue }}</span>
<select
<div class="field">
<v-select
v-if="step.targetType === 'Role'"
:value="step.targetValue"
:model-value="step.targetValue"
:items="roleItems"
:label="labels.fields.targetValue"
:disabled="disabled"
@change="updateStep(index, { targetValue: $event.target.value })"
>
<option
v-for="role in roleOptions"
:key="role"
:value="role"
>
{{ labels.roles[role] }}
</option>
</select>
variant="outlined"
hide-details
@update:model-value="updateStep(index, { targetValue: $event })"
/>
<select
<v-select
v-else-if="step.targetType === 'Membership'"
:value="step.targetValue"
:model-value="step.targetValue"
:items="membershipItems"
:label="labels.fields.targetValue"
:disabled="disabled"
@change="updateStep(index, { targetValue: $event.target.value })"
>
<option
v-for="membership in membershipOptions"
:key="membership"
:value="membership"
>
{{ labels.memberships[membership] }}
</option>
</select>
variant="outlined"
hide-details
@update:model-value="updateStep(index, { targetValue: $event })"
/>
<select
<v-select
v-else
:value="getSelectedMemberIds(step)"
:model-value="getSelectedMemberIds(step)"
:items="memberItems"
:label="labels.fields.targetValue"
:disabled="disabled"
multiple
size="5"
@change="updateMemberTargets(index, $event.target.selectedOptions)"
>
<option
v-for="member in members"
:key="member.id"
:value="member.id"
>
{{ member.displayName }} - {{ member.email }}
</option>
</select>
chips
closable-chips
variant="outlined"
hide-details
@update:model-value="updateMemberTargets(index, $event)"
/>
<small
v-if="step.targetType === 'Member'"
class="field-help"
@@ -292,17 +286,19 @@
>
{{ errors[index].targetValue }}
</small>
</label>
</div>
<label class="field">
<span>{{ labels.fields.requiredApproverCount }}</span>
<input
:value="step.requiredApproverCount"
<div class="field">
<v-text-field
:model-value="step.requiredApproverCount"
:label="labels.fields.requiredApproverCount"
type="number"
min="1"
step="1"
:disabled="disabled"
@input="updateStep(index, { requiredApproverCount: Number($event.target.value) })"
variant="outlined"
hide-details
@update:model-value="updateStep(index, { requiredApproverCount: Number($event) })"
/>
<small
v-if="errors[index]?.requiredApproverCount"
@@ -310,7 +306,7 @@
>
{{ errors[index].requiredApproverCount }}
</small>
</label>
</div>
</div>
</section>
</div>

View File

@@ -13,38 +13,23 @@
},
});
const emit = defineEmits(['update:modelValue']);
const timeZoneOptions = computed(() => getTimeZoneOptions(props.modelValue));
function updateValue(event) {
emit('update:modelValue', event.target.value);
}
</script>
<template>
<select
class="time-zone-select"
:value="modelValue"
<v-select
:model-value="modelValue"
:items="timeZoneOptions"
:disabled="disabled"
@change="updateValue"
>
<option
v-for="timeZone in timeZoneOptions"
:key="timeZone.value"
:value="timeZone.value"
>
{{ timeZone.label }}
</option>
</select>
item-title="label"
item-value="value"
density="compact"
variant="outlined"
hide-details
@update:model-value="$emit('update:modelValue', $event)"
/>
</template>
<style scoped>
@reference "@/assets/main.css";
.time-zone-select {
@apply rounded-[1rem] border px-4 py-3 text-sm;
background: #fffdf8;
border-color: rgba(23, 32, 51, 0.1);
color: #172033;
outline: none;
}
</style>

View File

@@ -82,43 +82,35 @@
{{ formError }}
</div>
<form
<v-form
class="form-grid"
@submit.prevent="submitForm"
>
<label class="field field-wide">
<span>{{ t('workspaceCreate.fields.name') }}</span>
<input
v-model="form.name"
type="text"
:placeholder="t('workspaceCreate.fields.namePlaceholder')"
:disabled="workspaceStore.isCreating"
/>
</label>
<v-text-field
v-model="form.name"
class="field-wide"
:label="t('workspaceCreate.fields.name')"
:placeholder="t('workspaceCreate.fields.namePlaceholder')"
:disabled="workspaceStore.isCreating"
variant="outlined"
hide-details
/>
<label class="field">
<span>{{ t('workspaceCreate.fields.organization') }}</span>
<select
v-model="selectedOrganizationId"
:disabled="workspaceStore.isCreating || organizationStore.organizations.length <= 1"
>
<option
v-for="organization in organizationStore.organizations"
:key="organization.id"
:value="organization.id"
>
{{ organization.name }}
</option>
</select>
</label>
<v-select
v-model="selectedOrganizationId"
:items="organizationStore.organizations"
:label="t('workspaceCreate.fields.organization')"
:disabled="workspaceStore.isCreating || organizationStore.organizations.length <= 1"
item-title="name"
item-value="id"
variant="outlined"
hide-details
/>
<label class="field">
<span>{{ t('workspaceCreate.fields.timeZone') }}</span>
<TimeZoneSelect
v-model="form.timeZone"
:disabled="workspaceStore.isCreating"
/>
</label>
<TimeZoneSelect
v-model="form.timeZone"
:disabled="workspaceStore.isCreating"
/>
<div class="panel-actions field-wide">
<button
@@ -137,7 +129,7 @@
{{ workspaceStore.isCreating ? t('common.creating') : t('workspaceCreate.createAction') }}
</button>
</div>
</form>
</v-form>
</article>
</section>
</template>

View File

@@ -460,7 +460,7 @@
{{ settingsStatus }}
</div>
<form
<v-form
class="form-stack"
@submit.prevent="submitWorkspaceSettings"
>
@@ -496,14 +496,13 @@
</button>
</div>
<label class="field">
<span>{{ t('workspaceSettings.fields.name') }}</span>
<input
v-model="settingsForm.name"
type="text"
:disabled="workspaceStore.isUpdating"
/>
</label>
<v-text-field
v-model="settingsForm.name"
:label="t('workspaceSettings.fields.name')"
:disabled="workspaceStore.isUpdating"
variant="outlined"
hide-details
/>
<label class="field">
<span>{{ t('workspaceSettings.fields.timeZone') }}</span>
@@ -520,7 +519,7 @@
>
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.general.saveAction') }}
</button>
</form>
</v-form>
</article>
</div>
@@ -535,26 +534,29 @@
<p>{{ t('workspaceSettings.inviteDescription') }}</p>
</div>
<form
<v-form
class="form-stack"
@submit.prevent="submitInvite"
>
<label class="field">
<span>{{ t('workspaceSettings.fields.memberEmail') }}</span>
<input
v-model="inviteForm.email"
type="email"
/>
</label>
<v-text-field
v-model="inviteForm.email"
:label="t('workspaceSettings.fields.memberEmail')"
type="email"
variant="outlined"
hide-details
/>
<label class="field">
<span>{{ t('workspaceSettings.fields.memberRole') }}</span>
<select v-model="inviteForm.role">
<option value="workspace-member">{{ t('workspaceSettings.roles.workspace-member') }}</option>
<option value="client">{{ t('workspaceSettings.roles.client') }}</option>
<option value="provider">{{ t('workspaceSettings.roles.provider') }}</option>
</select>
</label>
<v-select
v-model="inviteForm.role"
:items="[
{ title: t('workspaceSettings.roles.workspace-member'), value: 'workspace-member' },
{ title: t('workspaceSettings.roles.client'), value: 'client' },
{ title: t('workspaceSettings.roles.provider'), value: 'provider' },
]"
:label="t('workspaceSettings.fields.memberRole')"
variant="outlined"
hide-details
/>
<button
class="primary-button"
@@ -562,7 +564,7 @@
>
{{ workspaceStore.isInviting ? t('common.creating') : t('workspaceSettings.sendInvite') }}
</button>
</form>
</v-form>
</article>
<article class="settings-card">
@@ -667,21 +669,16 @@
</div>
<div class="workflow-rule-list">
<label class="field">
<span>{{ t('workspaceSettings.approvals.fields.approvalMode') }}</span>
<select
v-model="settingsForm.approvalMode"
:disabled="workspaceStore.isUpdating"
>
<option
v-for="option in approvalModeOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<v-select
v-model="settingsForm.approvalMode"
:items="approvalModeOptions"
:label="t('workspaceSettings.approvals.fields.approvalMode')"
:disabled="workspaceStore.isUpdating"
item-title="label"
item-value="value"
variant="outlined"
hide-details
/>
<div class="workflow-rule">
<strong>{{ activeApprovalModeOption.label }}</strong>
@@ -697,41 +694,44 @@
:labels="approvalWorkflowEditorLabels"
/>
<label class="workflow-toggle">
<input
<div class="workflow-toggle">
<v-checkbox
v-model="settingsForm.schedulePostsAutomaticallyOnApproval"
type="checkbox"
:disabled="workspaceStore.isUpdating"
density="compact"
hide-details
/>
<span>
<strong>{{ t('workspaceSettings.approvals.fields.schedulePostsAutomaticallyOnApproval') }}</strong>
<small>{{ t('workspaceSettings.approvals.fieldHelp.schedulePostsAutomaticallyOnApproval') }}</small>
</span>
</label>
</div>
<label class="workflow-toggle">
<input
<div class="workflow-toggle">
<v-checkbox
v-model="settingsForm.lockContentAfterApproval"
type="checkbox"
:disabled="workspaceStore.isUpdating"
density="compact"
hide-details
/>
<span>
<strong>{{ t('workspaceSettings.approvals.fields.lockContentAfterApproval') }}</strong>
<small>{{ t('workspaceSettings.approvals.fieldHelp.lockContentAfterApproval') }}</small>
</span>
</label>
</div>
<label class="workflow-toggle">
<input
<div class="workflow-toggle">
<v-checkbox
v-model="settingsForm.sendAutomaticApprovalReminders"
type="checkbox"
:disabled="workspaceStore.isUpdating"
density="compact"
hide-details
/>
<span>
<strong>{{ t('workspaceSettings.approvals.fields.sendAutomaticApprovalReminders') }}</strong>
<small>{{ t('workspaceSettings.approvals.fieldHelp.sendAutomaticApprovalReminders') }}</small>
</span>
</label>
</div>
<button
class="primary-button"

View File

@@ -284,25 +284,25 @@
ref="searchRef"
class="sidebar-search-wrap"
>
<label
<div
class="sidebar-search"
:class="{ 'sidebar-search-open': isSearchOpen }"
:title="!isExpanded ? 'Search' : null"
@click="openCollapsedSearch"
>
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<input
<v-text-field
v-if="isExpanded"
v-model="searchQuery"
type="search"
class="sidebar-search-input"
:prepend-inner-icon="mdiMagnify"
placeholder="Search"
density="compact"
variant="plain"
hide-details
type="search"
@focus="isSearchFocused = true"
/>
</label>
</div>
<div
v-if="isSearchPanelOpen"
@@ -310,22 +310,22 @@
:class="{ 'sidebar-search-panel-collapsed': !isExpanded }"
:style="!isExpanded ? collapsedSearchPanelStyle : null"
>
<label
<div
v-if="!isExpanded"
class="sidebar-search sidebar-search-panel-input"
>
<v-icon
:icon="mdiMagnify"
class="sidebar-search-icon"
/>
<input
<v-text-field
ref="collapsedSearchInputRef"
v-model="searchQuery"
type="search"
class="sidebar-search-input"
:prepend-inner-icon="mdiMagnify"
placeholder="Search"
density="compact"
variant="plain"
hide-details
type="search"
/>
</label>
</div>
<div
v-if="campaignResults.length"

View File

@@ -9,11 +9,16 @@ import {
VAlert,
VApp,
VBtn,
VCheckbox,
VCheckboxBtn,
VDialog,
VFileInput,
VForm,
VIcon,
VProgressCircular,
VProgressLinear,
VRadio,
VRadioGroup,
VSelect,
VSnackbar,
VTextarea,
@@ -42,9 +47,14 @@ const vuetify = createVuetify({
VDialog,
VApp,
VBtn,
VCheckbox,
VCheckboxBtn,
VFileInput,
VProgressLinear,
VProgressCircular,
VIcon,
VRadio,
VRadioGroup,
VSelect,
VTextField,
VSnackbar,