| 1 | <template>
|
|---|
| 2 | <main class="admin-listings">
|
|---|
| 3 | <section class="header-section">
|
|---|
| 4 | <div class="container">
|
|---|
| 5 | <h1 class="page-title">Listings</h1>
|
|---|
| 6 | <p class="page-subtitle">
|
|---|
| 7 | Browse all listings in the system. Results are paginated (500 per page).
|
|---|
| 8 | </p>
|
|---|
| 9 | </div>
|
|---|
| 10 | </section>
|
|---|
| 11 |
|
|---|
| 12 | <section class="container listings-body">
|
|---|
| 13 | <div v-if="errorMessage" class="alert alert-danger">{{ errorMessage }}</div>
|
|---|
| 14 |
|
|---|
| 15 | <section class="panel">
|
|---|
| 16 | <div class="panel-header">
|
|---|
| 17 | <div>
|
|---|
| 18 | <h2>All Listings</h2>
|
|---|
| 19 | <p v-if="pageData" class="panel-subtitle">
|
|---|
| 20 | Total: {{ pageData.totalItems }} | Active: {{ pageData.activeListings }} | Sold: {{ pageData.soldListings }}
|
|---|
| 21 | </p>
|
|---|
| 22 | </div>
|
|---|
| 23 |
|
|---|
| 24 | <div class="panel-actions">
|
|---|
| 25 | <button class="btn btn-sm btn-outline-secondary" type="button" :disabled="isLoading" @click="refresh">
|
|---|
| 26 | Refresh
|
|---|
| 27 | </button>
|
|---|
| 28 | </div>
|
|---|
| 29 | </div>
|
|---|
| 30 |
|
|---|
| 31 | <div class="pagination-bar" v-if="pageData">
|
|---|
| 32 | <button
|
|---|
| 33 | class="btn btn-sm btn-outline-secondary"
|
|---|
| 34 | type="button"
|
|---|
| 35 | :disabled="isLoading || !pageData.hasPrevious"
|
|---|
| 36 | @click="goPrevious"
|
|---|
| 37 | >
|
|---|
| 38 | Previous
|
|---|
| 39 | </button>
|
|---|
| 40 |
|
|---|
| 41 | <div class="page-meta">
|
|---|
| 42 | Page {{ pageData.page + 1 }} / {{ Math.max(1, pageData.totalPages) }} (size: {{ pageData.size }})
|
|---|
| 43 | </div>
|
|---|
| 44 |
|
|---|
| 45 | <button
|
|---|
| 46 | class="btn btn-sm btn-outline-secondary"
|
|---|
| 47 | type="button"
|
|---|
| 48 | :disabled="isLoading || !pageData.hasNext"
|
|---|
| 49 | @click="goNext"
|
|---|
| 50 | >
|
|---|
| 51 | Next
|
|---|
| 52 | </button>
|
|---|
| 53 | </div>
|
|---|
| 54 |
|
|---|
| 55 | <div v-if="pageData" class="filter-bar">
|
|---|
| 56 | <div class="filter-field search-field">
|
|---|
| 57 | <label for="listingSearch">Search</label>
|
|---|
| 58 | <input
|
|---|
| 59 | id="listingSearch"
|
|---|
| 60 | v-model="filters.search"
|
|---|
| 61 | class="form-control form-control-sm"
|
|---|
| 62 | placeholder="Pet, owner, or description"
|
|---|
| 63 | />
|
|---|
| 64 | </div>
|
|---|
| 65 |
|
|---|
| 66 | <div class="filter-field">
|
|---|
| 67 | <label for="listingStatus">Status</label>
|
|---|
| 68 | <select id="listingStatus" v-model="filters.status" class="form-select form-select-sm">
|
|---|
| 69 | <option value="">All</option>
|
|---|
| 70 | <option v-for="status in availableStatuses" :key="status" :value="status">{{ status }}</option>
|
|---|
| 71 | </select>
|
|---|
| 72 | </div>
|
|---|
| 73 |
|
|---|
| 74 | <div class="filter-field">
|
|---|
| 75 | <label for="minPrice">Min price</label>
|
|---|
| 76 | <input id="minPrice" v-model="filters.minPrice" class="form-control form-control-sm" type="number" min="0" step="1" @change="applyFilters" />
|
|---|
| 77 | </div>
|
|---|
| 78 |
|
|---|
| 79 | <div class="filter-field">
|
|---|
| 80 | <label for="maxPrice">Max price</label>
|
|---|
| 81 | <input id="maxPrice" v-model="filters.maxPrice" class="form-control form-control-sm" type="number" min="0" step="1" @change="applyFilters" />
|
|---|
| 82 | </div>
|
|---|
| 83 |
|
|---|
| 84 | <button class="btn btn-sm btn-outline-secondary clear-filters" type="button" @click="clearFilters">
|
|---|
| 85 | Clear
|
|---|
| 86 | </button>
|
|---|
| 87 | </div>
|
|---|
| 88 |
|
|---|
| 89 | <p v-if="pageData && hasActiveFilters" class="filter-summary">
|
|---|
| 90 | Showing {{ filteredListings.length }} of {{ pageData.totalItems }} listings.
|
|---|
| 91 | </p>
|
|---|
| 92 |
|
|---|
| 93 | <div v-if="isLoading" class="alert alert-info">Loading listings...</div>
|
|---|
| 94 |
|
|---|
| 95 | <div v-if="!isLoading && (!pageData || pageData.items.length === 0)" class="empty-state">
|
|---|
| 96 | No listings found.
|
|---|
| 97 | </div>
|
|---|
| 98 |
|
|---|
| 99 | <div v-else-if="!isLoading && filteredListings.length === 0" class="empty-state">
|
|---|
| 100 | No listings match the selected filters.
|
|---|
| 101 | </div>
|
|---|
| 102 |
|
|---|
| 103 | <div v-if="!isLoading && filteredListings.length" class="listings-grid">
|
|---|
| 104 | <article v-for="listing in filteredListings" :key="listing.listingId" class="listing-card">
|
|---|
| 105 | <div class="listing-card-header">
|
|---|
| 106 | <div>
|
|---|
| 107 | <RouterLink v-if="listing.listingId" class="listing-title" :to="`/listing/${listing.listingId}`">
|
|---|
| 108 | {{ getAnimalName(listing.animalId) }}
|
|---|
| 109 | </RouterLink>
|
|---|
| 110 | <h3 v-else class="listing-title">{{ getAnimalName(listing.animalId) }}</h3>
|
|---|
| 111 | <p class="listing-owner">{{ getOwnerName(listing.ownerId) }}</p>
|
|---|
| 112 | </div>
|
|---|
| 113 | <span class="badge" :class="statusClass(listing.status)">{{ listing.status }}</span>
|
|---|
| 114 | </div>
|
|---|
| 115 |
|
|---|
| 116 | <p class="listing-description">{{ listing.description || 'No description' }}</p>
|
|---|
| 117 |
|
|---|
| 118 | <div class="listing-footer">
|
|---|
| 119 | <span class="listing-price">{{ formatPrice(listing.price) }}</span>
|
|---|
| 120 | <span class="listing-date">{{ formatDate(listing.createdAt) }}</span>
|
|---|
| 121 | </div>
|
|---|
| 122 | </article>
|
|---|
| 123 | </div>
|
|---|
| 124 |
|
|---|
| 125 | <div class="pagination-bar" v-if="pageData">
|
|---|
| 126 | <button
|
|---|
| 127 | class="btn btn-sm btn-outline-secondary"
|
|---|
| 128 | type="button"
|
|---|
| 129 | :disabled="isLoading || !pageData.hasPrevious"
|
|---|
| 130 | @click="goPrevious"
|
|---|
| 131 | >
|
|---|
| 132 | Previous
|
|---|
| 133 | </button>
|
|---|
| 134 |
|
|---|
| 135 | <div class="page-meta">
|
|---|
| 136 | Page {{ pageData.page + 1 }} / {{ Math.max(1, pageData.totalPages) }}
|
|---|
| 137 | </div>
|
|---|
| 138 |
|
|---|
| 139 | <button
|
|---|
| 140 | class="btn btn-sm btn-outline-secondary"
|
|---|
| 141 | type="button"
|
|---|
| 142 | :disabled="isLoading || !pageData.hasNext"
|
|---|
| 143 | @click="goNext"
|
|---|
| 144 | >
|
|---|
| 145 | Next
|
|---|
| 146 | </button>
|
|---|
| 147 | </div>
|
|---|
| 148 | </section>
|
|---|
| 149 | </section>
|
|---|
| 150 | </main>
|
|---|
| 151 | </template>
|
|---|
| 152 |
|
|---|
| 153 | <script setup lang="ts">
|
|---|
| 154 | import { computed, onMounted, ref, watch } from 'vue'
|
|---|
| 155 | import { useRouter } from 'vue-router'
|
|---|
| 156 | import { getAllListings, getAllUsers, type AdminListingsPage } from '../api/admin'
|
|---|
| 157 | import { getPet } from '../api/profile'
|
|---|
| 158 | import { useAuthStore } from '../stores/auth'
|
|---|
| 159 |
|
|---|
| 160 | const router = useRouter()
|
|---|
| 161 | const auth = useAuthStore()
|
|---|
| 162 |
|
|---|
| 163 | const pageData = ref<AdminListingsPage | null>(null)
|
|---|
| 164 | const page = ref(0)
|
|---|
| 165 | const isLoading = ref(false)
|
|---|
| 166 | const errorMessage = ref('')
|
|---|
| 167 | const ownerNameMap = ref<Record<number, string>>({})
|
|---|
| 168 | const animalNameMap = ref<Record<number, string>>({})
|
|---|
| 169 | const filters = ref({
|
|---|
| 170 | search: '',
|
|---|
| 171 | status: '',
|
|---|
| 172 | minPrice: '',
|
|---|
| 173 | maxPrice: '',
|
|---|
| 174 | })
|
|---|
| 175 |
|
|---|
| 176 | const PAGE_SIZE = 500
|
|---|
| 177 | const availableStatuses = ['ACTIVE', 'SOLD', 'DRAFT', 'ARCHIVED']
|
|---|
| 178 |
|
|---|
| 179 | let latestListingsRequest = 0
|
|---|
| 180 |
|
|---|
| 181 | const hasActiveFilters = computed(() => {
|
|---|
| 182 | return Boolean(filters.value.search.trim() || filters.value.status || filters.value.minPrice || filters.value.maxPrice)
|
|---|
| 183 | })
|
|---|
| 184 |
|
|---|
| 185 | const filteredListings = computed(() => {
|
|---|
| 186 | const listings = pageData.value?.items || []
|
|---|
| 187 | const search = filters.value.search.trim().toLowerCase()
|
|---|
| 188 |
|
|---|
| 189 | return listings.filter((listing: any) => {
|
|---|
| 190 | const matchesSearch = !search || [
|
|---|
| 191 | getAnimalName(listing.animalId),
|
|---|
| 192 | getOwnerName(listing.ownerId),
|
|---|
| 193 | listing.description || '',
|
|---|
| 194 | listing.status || '',
|
|---|
| 195 | ].join(' ').toLowerCase().includes(search)
|
|---|
| 196 |
|
|---|
| 197 | return matchesSearch
|
|---|
| 198 | })
|
|---|
| 199 | })
|
|---|
| 200 |
|
|---|
| 201 | async function loadPage(targetPage: number) {
|
|---|
| 202 | if (!auth.user?.userId) return
|
|---|
| 203 |
|
|---|
| 204 | const requestId = ++latestListingsRequest
|
|---|
| 205 | isLoading.value = true
|
|---|
| 206 | errorMessage.value = ''
|
|---|
| 207 | try {
|
|---|
| 208 | const data = await getAllListings(auth.user.userId, targetPage, PAGE_SIZE, {
|
|---|
| 209 | status: filters.value.status,
|
|---|
| 210 | minPrice: filters.value.minPrice,
|
|---|
| 211 | maxPrice: filters.value.maxPrice,
|
|---|
| 212 | })
|
|---|
| 213 | if (requestId !== latestListingsRequest) return
|
|---|
| 214 | pageData.value = data
|
|---|
| 215 | page.value = data.page
|
|---|
| 216 | await loadNamesForPage(data.items)
|
|---|
| 217 | } catch (error) {
|
|---|
| 218 | if (requestId !== latestListingsRequest) return
|
|---|
| 219 | pageData.value = null
|
|---|
| 220 | errorMessage.value = error instanceof Error ? error.message : 'Failed to load listings'
|
|---|
| 221 | } finally {
|
|---|
| 222 | if (requestId === latestListingsRequest) {
|
|---|
| 223 | isLoading.value = false
|
|---|
| 224 | }
|
|---|
| 225 | }
|
|---|
| 226 | }
|
|---|
| 227 |
|
|---|
| 228 | async function loadNamesForPage(listings: any[]) {
|
|---|
| 229 | const ownerIds = Array.from(new Set(
|
|---|
| 230 | listings
|
|---|
| 231 | .map((listing) => Number(listing.ownerId))
|
|---|
| 232 | .filter((ownerId) => Number.isFinite(ownerId) && !ownerNameMap.value[ownerId])
|
|---|
| 233 | ))
|
|---|
| 234 | const animalIds = Array.from(new Set(
|
|---|
| 235 | listings
|
|---|
| 236 | .map((listing) => Number(listing.animalId))
|
|---|
| 237 | .filter((animalId) => Number.isFinite(animalId) && !animalNameMap.value[animalId])
|
|---|
| 238 | ))
|
|---|
| 239 |
|
|---|
| 240 | if (ownerIds.length > 0 && auth.user?.userId) {
|
|---|
| 241 | try {
|
|---|
| 242 | const users = await getAllUsers(auth.user.userId)
|
|---|
| 243 | const nextOwnerMap = { ...ownerNameMap.value }
|
|---|
| 244 | users.forEach((user: any) => {
|
|---|
| 245 | if (user.userId) {
|
|---|
| 246 | nextOwnerMap[Number(user.userId)] = `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.username || `Owner #${user.userId}`
|
|---|
| 247 | }
|
|---|
| 248 | })
|
|---|
| 249 | ownerNameMap.value = nextOwnerMap
|
|---|
| 250 | } catch (error) {
|
|---|
| 251 | console.error('Failed to load owner names:', error)
|
|---|
| 252 | }
|
|---|
| 253 | }
|
|---|
| 254 |
|
|---|
| 255 | if (animalIds.length > 0) {
|
|---|
| 256 | const entries = await Promise.all(animalIds.map(async (animalId) => {
|
|---|
| 257 | try {
|
|---|
| 258 | const pet = await getPet(animalId)
|
|---|
| 259 | return [animalId, pet.name || `Pet #${animalId}`] as const
|
|---|
| 260 | } catch {
|
|---|
| 261 | return [animalId, `Pet #${animalId}`] as const
|
|---|
| 262 | }
|
|---|
| 263 | }))
|
|---|
| 264 | animalNameMap.value = {
|
|---|
| 265 | ...animalNameMap.value,
|
|---|
| 266 | ...Object.fromEntries(entries),
|
|---|
| 267 | }
|
|---|
| 268 | }
|
|---|
| 269 | }
|
|---|
| 270 |
|
|---|
| 271 | function getOwnerName(ownerId: number | undefined) {
|
|---|
| 272 | if (!ownerId) return 'Unknown owner'
|
|---|
| 273 | return ownerNameMap.value[ownerId] || 'Loading owner...'
|
|---|
| 274 | }
|
|---|
| 275 |
|
|---|
| 276 | function getAnimalName(animalId: number | undefined) {
|
|---|
| 277 | if (!animalId) return 'Unknown pet'
|
|---|
| 278 | return animalNameMap.value[animalId] || 'Loading pet...'
|
|---|
| 279 | }
|
|---|
| 280 |
|
|---|
| 281 | function clearFilters() {
|
|---|
| 282 | filters.value = {
|
|---|
| 283 | search: '',
|
|---|
| 284 | status: '',
|
|---|
| 285 | minPrice: '',
|
|---|
| 286 | maxPrice: '',
|
|---|
| 287 | }
|
|---|
| 288 | void loadPage(0)
|
|---|
| 289 | }
|
|---|
| 290 |
|
|---|
| 291 | async function applyFilters() {
|
|---|
| 292 | await loadPage(0)
|
|---|
| 293 | }
|
|---|
| 294 |
|
|---|
| 295 | async function refresh() {
|
|---|
| 296 | await loadPage(page.value)
|
|---|
| 297 | }
|
|---|
| 298 |
|
|---|
| 299 | async function goNext() {
|
|---|
| 300 | if (!pageData.value?.hasNext) return
|
|---|
| 301 | await loadPage(page.value + 1)
|
|---|
| 302 | }
|
|---|
| 303 |
|
|---|
| 304 | async function goPrevious() {
|
|---|
| 305 | if (!pageData.value?.hasPrevious) return
|
|---|
| 306 | await loadPage(Math.max(0, page.value - 1))
|
|---|
| 307 | }
|
|---|
| 308 |
|
|---|
| 309 | function formatDate(value: string | undefined) {
|
|---|
| 310 | if (!value) return ''
|
|---|
| 311 | const date = new Date(value)
|
|---|
| 312 | if (Number.isNaN(date.getTime())) return value
|
|---|
| 313 | return date.toLocaleString('en-US', {
|
|---|
| 314 | year: 'numeric',
|
|---|
| 315 | month: 'short',
|
|---|
| 316 | day: 'numeric',
|
|---|
| 317 | })
|
|---|
| 318 | }
|
|---|
| 319 |
|
|---|
| 320 | function formatPrice(value: unknown) {
|
|---|
| 321 | if (value === null || value === undefined) return ''
|
|---|
| 322 | const numberValue = Number(value)
|
|---|
| 323 | if (!Number.isFinite(numberValue)) return String(value)
|
|---|
| 324 | return new Intl.NumberFormat('en-US', {
|
|---|
| 325 | style: 'currency',
|
|---|
| 326 | currency: 'USD',
|
|---|
| 327 | }).format(numberValue)
|
|---|
| 328 | }
|
|---|
| 329 |
|
|---|
| 330 | function statusClass(status: string) {
|
|---|
| 331 | const normalized = String(status || '').toUpperCase()
|
|---|
| 332 | if (normalized === 'ACTIVE') return 'bg-success'
|
|---|
| 333 | if (normalized === 'SOLD') return 'bg-secondary'
|
|---|
| 334 | if (normalized === 'PENDING') return 'bg-warning'
|
|---|
| 335 | return 'bg-dark'
|
|---|
| 336 | }
|
|---|
| 337 |
|
|---|
| 338 | watch(
|
|---|
| 339 | () => filters.value.status,
|
|---|
| 340 | async () => {
|
|---|
| 341 | if (!auth.isAuthenticated || auth.user?.userType !== 'ADMIN') return
|
|---|
| 342 | await loadPage(0)
|
|---|
| 343 | }
|
|---|
| 344 | )
|
|---|
| 345 |
|
|---|
| 346 | onMounted(async () => {
|
|---|
| 347 | if (!auth.isAuthenticated) {
|
|---|
| 348 | router.push('/login')
|
|---|
| 349 | return
|
|---|
| 350 | }
|
|---|
| 351 | if (auth.user?.userType !== 'ADMIN') {
|
|---|
| 352 | router.push('/')
|
|---|
| 353 | return
|
|---|
| 354 | }
|
|---|
| 355 |
|
|---|
| 356 | await loadPage(0)
|
|---|
| 357 | })
|
|---|
| 358 | </script>
|
|---|
| 359 |
|
|---|
| 360 | <style scoped>
|
|---|
| 361 | .admin-listings {
|
|---|
| 362 | min-height: 100vh;
|
|---|
| 363 | background: #f7fafc;
|
|---|
| 364 | padding-bottom: 48px;
|
|---|
| 365 | }
|
|---|
| 366 |
|
|---|
| 367 | .header-section {
|
|---|
| 368 | background: white;
|
|---|
| 369 | border-bottom: 1px solid #e2e8f0;
|
|---|
| 370 | padding: 32px 0;
|
|---|
| 371 | }
|
|---|
| 372 |
|
|---|
| 373 | .page-title {
|
|---|
| 374 | color: #1a202c;
|
|---|
| 375 | font-size: 2rem;
|
|---|
| 376 | margin: 0;
|
|---|
| 377 | }
|
|---|
| 378 |
|
|---|
| 379 | .page-subtitle {
|
|---|
| 380 | color: #718096;
|
|---|
| 381 | margin: 8px 0 0;
|
|---|
| 382 | }
|
|---|
| 383 |
|
|---|
| 384 | .listings-body {
|
|---|
| 385 | padding-top: 28px;
|
|---|
| 386 | }
|
|---|
| 387 |
|
|---|
| 388 | .panel {
|
|---|
| 389 | background: white;
|
|---|
| 390 | border: 1px solid #e2e8f0;
|
|---|
| 391 | border-radius: 8px;
|
|---|
| 392 | padding: 24px;
|
|---|
| 393 | }
|
|---|
| 394 |
|
|---|
| 395 | .panel-header {
|
|---|
| 396 | display: flex;
|
|---|
| 397 | align-items: flex-start;
|
|---|
| 398 | justify-content: space-between;
|
|---|
| 399 | gap: 16px;
|
|---|
| 400 | margin-bottom: 18px;
|
|---|
| 401 | }
|
|---|
| 402 |
|
|---|
| 403 | .panel-header h2 {
|
|---|
| 404 | color: #1a202c;
|
|---|
| 405 | margin: 0;
|
|---|
| 406 | }
|
|---|
| 407 |
|
|---|
| 408 | .panel-subtitle {
|
|---|
| 409 | color: #718096;
|
|---|
| 410 | margin: 6px 0 0;
|
|---|
| 411 | }
|
|---|
| 412 |
|
|---|
| 413 | .panel-actions {
|
|---|
| 414 | display: flex;
|
|---|
| 415 | gap: 10px;
|
|---|
| 416 | }
|
|---|
| 417 |
|
|---|
| 418 | .pagination-bar {
|
|---|
| 419 | display: flex;
|
|---|
| 420 | align-items: center;
|
|---|
| 421 | justify-content: space-between;
|
|---|
| 422 | gap: 12px;
|
|---|
| 423 | margin: 12px 0;
|
|---|
| 424 | }
|
|---|
| 425 |
|
|---|
| 426 | .page-meta {
|
|---|
| 427 | color: #718096;
|
|---|
| 428 | font-weight: 600;
|
|---|
| 429 | }
|
|---|
| 430 |
|
|---|
| 431 | .filter-bar {
|
|---|
| 432 | align-items: end;
|
|---|
| 433 | background: #f8fafc;
|
|---|
| 434 | border: 1px solid #e2e8f0;
|
|---|
| 435 | border-radius: 8px;
|
|---|
| 436 | display: grid;
|
|---|
| 437 | gap: 12px;
|
|---|
| 438 | grid-template-columns: minmax(220px, 1fr) 140px 120px 120px auto;
|
|---|
| 439 | margin: 16px 0;
|
|---|
| 440 | padding: 14px;
|
|---|
| 441 | }
|
|---|
| 442 |
|
|---|
| 443 | .filter-field {
|
|---|
| 444 | display: grid;
|
|---|
| 445 | gap: 6px;
|
|---|
| 446 | }
|
|---|
| 447 |
|
|---|
| 448 | .filter-field label {
|
|---|
| 449 | color: #4a5568;
|
|---|
| 450 | font-size: 0.82rem;
|
|---|
| 451 | font-weight: 700;
|
|---|
| 452 | }
|
|---|
| 453 |
|
|---|
| 454 | .clear-filters {
|
|---|
| 455 | min-width: 72px;
|
|---|
| 456 | }
|
|---|
| 457 |
|
|---|
| 458 | .filter-summary {
|
|---|
| 459 | color: #718096;
|
|---|
| 460 | font-size: 0.9rem;
|
|---|
| 461 | margin: -4px 0 14px;
|
|---|
| 462 | }
|
|---|
| 463 |
|
|---|
| 464 | .listings-grid {
|
|---|
| 465 | display: grid;
|
|---|
| 466 | gap: 12px;
|
|---|
| 467 | grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|---|
| 468 | }
|
|---|
| 469 |
|
|---|
| 470 | .listing-card {
|
|---|
| 471 | background: #ffffff;
|
|---|
| 472 | border: 1px solid #e2e8f0;
|
|---|
| 473 | border-radius: 8px;
|
|---|
| 474 | display: flex;
|
|---|
| 475 | flex-direction: column;
|
|---|
| 476 | gap: 14px;
|
|---|
| 477 | min-height: 190px;
|
|---|
| 478 | padding: 16px;
|
|---|
| 479 | }
|
|---|
| 480 |
|
|---|
| 481 | .listing-card-header {
|
|---|
| 482 | align-items: flex-start;
|
|---|
| 483 | display: flex;
|
|---|
| 484 | gap: 12px;
|
|---|
| 485 | justify-content: space-between;
|
|---|
| 486 | }
|
|---|
| 487 |
|
|---|
| 488 | .listing-title {
|
|---|
| 489 | color: #1a202c;
|
|---|
| 490 | display: block;
|
|---|
| 491 | font-size: 1.05rem;
|
|---|
| 492 | font-weight: 800;
|
|---|
| 493 | line-height: 1.25;
|
|---|
| 494 | text-decoration: none;
|
|---|
| 495 | }
|
|---|
| 496 |
|
|---|
| 497 | .listing-title:hover {
|
|---|
| 498 | color: #f97316;
|
|---|
| 499 | }
|
|---|
| 500 |
|
|---|
| 501 | .listing-owner,
|
|---|
| 502 | .listing-date {
|
|---|
| 503 | color: #718096;
|
|---|
| 504 | margin: 4px 0 0;
|
|---|
| 505 | }
|
|---|
| 506 |
|
|---|
| 507 | .listing-description {
|
|---|
| 508 | color: #4a5568;
|
|---|
| 509 | display: -webkit-box;
|
|---|
| 510 | line-height: 1.45;
|
|---|
| 511 | margin: 0;
|
|---|
| 512 | overflow: hidden;
|
|---|
| 513 | -webkit-box-orient: vertical;
|
|---|
| 514 | -webkit-line-clamp: 3;
|
|---|
| 515 | }
|
|---|
| 516 |
|
|---|
| 517 | .listing-footer {
|
|---|
| 518 | align-items: center;
|
|---|
| 519 | border-top: 1px solid #edf2f7;
|
|---|
| 520 | display: flex;
|
|---|
| 521 | justify-content: space-between;
|
|---|
| 522 | margin-top: auto;
|
|---|
| 523 | padding-top: 12px;
|
|---|
| 524 | }
|
|---|
| 525 |
|
|---|
| 526 | .listing-price {
|
|---|
| 527 | color: #f97316;
|
|---|
| 528 | font-size: 1.05rem;
|
|---|
| 529 | font-weight: 800;
|
|---|
| 530 | }
|
|---|
| 531 |
|
|---|
| 532 | .badge {
|
|---|
| 533 | border-radius: 4px;
|
|---|
| 534 | color: white;
|
|---|
| 535 | font-size: 0.78rem;
|
|---|
| 536 | font-weight: 700;
|
|---|
| 537 | padding: 4px 8px;
|
|---|
| 538 | }
|
|---|
| 539 |
|
|---|
| 540 | .bg-success {
|
|---|
| 541 | background: #16a34a;
|
|---|
| 542 | }
|
|---|
| 543 |
|
|---|
| 544 | .bg-secondary {
|
|---|
| 545 | background: #64748b;
|
|---|
| 546 | }
|
|---|
| 547 |
|
|---|
| 548 | .bg-warning {
|
|---|
| 549 | background: #f59e0b;
|
|---|
| 550 | }
|
|---|
| 551 |
|
|---|
| 552 | .bg-dark {
|
|---|
| 553 | background: #111827;
|
|---|
| 554 | }
|
|---|
| 555 |
|
|---|
| 556 | .empty-state {
|
|---|
| 557 | color: #718096;
|
|---|
| 558 | padding: 20px;
|
|---|
| 559 | text-align: center;
|
|---|
| 560 | }
|
|---|
| 561 |
|
|---|
| 562 | @media (max-width: 900px) {
|
|---|
| 563 | .filter-bar {
|
|---|
| 564 | grid-template-columns: 1fr 1fr;
|
|---|
| 565 | }
|
|---|
| 566 |
|
|---|
| 567 | .search-field {
|
|---|
| 568 | grid-column: 1 / -1;
|
|---|
| 569 | }
|
|---|
| 570 | }
|
|---|
| 571 |
|
|---|
| 572 | @media (max-width: 560px) {
|
|---|
| 573 | .filter-bar,
|
|---|
| 574 | .pagination-bar,
|
|---|
| 575 | .panel-header {
|
|---|
| 576 | align-items: stretch;
|
|---|
| 577 | grid-template-columns: 1fr;
|
|---|
| 578 | }
|
|---|
| 579 |
|
|---|
| 580 | .pagination-bar,
|
|---|
| 581 | .panel-header {
|
|---|
| 582 | flex-direction: column;
|
|---|
| 583 | }
|
|---|
| 584 | }
|
|---|
| 585 | </style>
|
|---|