openvibers/openvibe.rent Commits

Rent: listing cards (kind tile, title, where, chips, price), the search as one panel, a shorter hero, and what posting asks for on /post

OP OpenVibers committed 8 Oct 2026 · 0c3ada00df · 2 files, +59 −15


Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex 4713561..604fdc7 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -37,19 +37,49 @@ form.inline { display: inline; }
.prose table { margin: .6rem 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
--/* Listings: one row per listing, plain enough to read without JavaScript. */
++/* Listings: one card per listing — what kind, the title, where, the price on the right. */
.txt { white-space: pre-line; }
--.listing-list { list-style: none; margin: 0; padding: 0; }
--.listing { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; padding: 10px 0; border-bottom: 1px solid var(--border, #1f2d47); }
--.listing-title { flex: 1 1 22rem; font-weight: 600; overflow-wrap: anywhere; }
--.listing-meta { flex: 1 1 18rem; }
--.listing-price { white-space: nowrap; }
++.listing-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 10px; }
++.listing { --k: 212; display: grid; grid-template-columns: 52px minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); transition: border-color .15s, transform .15s; }
++.listing:hover { border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
++.listing.kind-room { --k: 265; } .listing.kind-house { --k: 150; } .listing.kind-commercial { --k: 32; } .listing.kind-parking { --k: 190; } .listing.kind-equipment { --k: 340; } .listing.kind-other { --k: 220; }
++.listing-mark { width: 52px; height: 52px; border-radius: 13px; display: grid; place-items: center; font-size: .72rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; color: hsl(var(--k) 80% 78%); background: hsl(var(--k) 45% 20%); border: 1px solid hsl(var(--k) 40% 30%); }
++.listing-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
++.listing-title { font-weight: 650; font-size: 1.04rem; color: var(--text-primary, #e6edf7); text-decoration: none; overflow-wrap: anywhere; }
++.listing-title:hover { color: var(--accent-light, #60a5fa); text-decoration: underline; text-underline-offset: 3px; }
++.listing-where { font-size: .9rem; color: var(--text-secondary, #96a7c2); }
++.listing-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 4px; }
++.chip { font-size: .75rem; font-weight: 600; padding: 2px 9px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); white-space: nowrap; }
++.listing-age { font-size: .78rem; color: var(--text-muted, #7386a3); }
++.listing-price { text-align: right; }
++.listing-price .price { font-size: 1.02rem; color: var(--text-primary, #e6edf7); }
.price { white-space: nowrap; }
.listing-facts { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; }
.facets { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0; }
--.field-row { display: flex; flex-wrap: wrap; gap: 12px; }
--.field-row .field { flex: 1 1 12rem; }
--.search-big input[type="search"] { font-size: 1.05rem; min-height: 2.4rem; }
++@media (max-width: 620px) {
++ .listing { grid-template-columns: 44px minmax(0, 1fr); }
++ .listing-mark { width: 44px; height: 44px; }
++ .listing-price { grid-column: 2; text-align: left; }
++}
++@media (prefers-reduced-motion: reduce) { .listing { transition: none; } }
++
++/* The search: one panel, the big box first, the filters in a grid. */
++.search-form { display: grid; gap: 12px; margin: 14px 0 0; padding: 18px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.search-form .field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
++.search-form label { font-size: .8rem; font-weight: 650; color: var(--text-secondary, #96a7c2); }
++.search-form input, .search-form select { font: inherit; width: 100%; min-width: 0; height: 40px; padding: 0 11px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }
++.search-form input[type="search"] { height: 46px; font-size: 1.02rem; }
++.search-form input:focus-visible, .search-form select:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); outline-offset: 1px; border-color: var(--accent, #3b82f6); }
++.field-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
++.search-form > p { display: flex; gap: 8px; margin: 2px 0 0; }
++@media (max-width: 620px) { .field-row { grid-template-columns: 1fr 1fr; } }
++
++/* Signed out on /post: what posting asks for. */
++.post-preview { margin-top: 18px; }
++.need-list { list-style: none; margin: 10px 0 12px; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
++.need-list li { display: flex; flex-direction: column; gap: 4px; padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
++.need-list b { color: var(--text-primary, #e6edf7); }
++.need-list span { font-size: .9rem; line-height: 1.45; color: var(--text-secondary, #96a7c2); }
.saved-list, .report-list { list-style: none; padding: 0; }
.saved-list > li, .staff-item { padding: 8px 0; }
.staff-item { border-bottom: 1px solid var(--border, #1f2d47); }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex f40d5bd..a942e47 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -58,10 +58,15 @@ function createPageRoutes(ctx) {
const whereOf = (row) => [row.neighbourhood, row.city, row.country].filter(Boolean).join(', ');
const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
-- /** One listing in a list: the title, the price, where it is and how old it is. */
-- const listingItem = (row) => html`<li class="listing">
++ /** One listing in a list, as a card: what kind it is at a glance, the title, where, the price on the right. */
++ const KIND_MARK = { apartment: 'Apt', room: 'Room', house: 'House', commercial: 'Work', parking: 'Park', equipment: 'Gear', other: 'More' };
++ const listingItem = (row) => html`<li class="listing kind-${listings.KINDS.includes(row.kind) ? row.kind : 'other'}">
++<span class="listing-mark" aria-hidden="true">${KIND_MARK[row.kind] || 'More'}</span>
++<span class="listing-main">
<a class="listing-title" href="/listings/${row.id}">${row.title}</a>
--<span class="listing-meta small muted">${listings.KIND_TEXT[row.kind] || row.kind} · ${whereOf(row)}${row.bedrooms == null ? '' : ` · ${row.bedrooms} bedroom${Number(row.bedrooms) === 1 ? '' : 's'}`} · posted ${time(row.created_at)}</span>
++<span class="listing-where">${whereOf(row)}</span>
++<span class="listing-chips"><span class="chip">${listings.KIND_TEXT[row.kind] || row.kind}</span>${row.bedrooms == null ? '' : html`<span class="chip">${row.bedrooms} bedroom${Number(row.bedrooms) === 1 ? '' : 's'}</span>`}${row.available_from ? html`<span class="chip">from ${row.available_from}</span>` : ''}<span class="listing-age">posted ${time(row.created_at)}</span></span>
++</span>
<span class="listing-price">${priceOf(row)}</span>
</li>`;
@@@ -147,8 +152,8 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
const latest = await store.search(s, listings.normalizeFilters({}), { limit: HOME_LATEST });
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
-- title: 'Somewhere to live, work or park',
-- accent: 'Every listing here was posted by a person.',
++ title: 'Find somewhere to live, work or park.',
++ accent: 'Posted by people.',
lede: `${SITE_NAME} is one place to find and offer things to rent: apartments, rooms, houses, commercial space, parking and storage, and equipment. In this first version people post the listings here themselves — nothing is scraped from another site, and nothing is paid for here.`,
actions: signedIn(req)
? [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Your listings', href: '/mine' }]
@@@ -358,7 +363,16 @@ ${listings.KINDS.map((k) => opt(k, listings.KIND_TEXT[k], v.kind))}
title: 'Post a listing',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Post a listing' }],
body: html`<h1>Post a listing</h1>${signInPrompt('A listing belongs to your OpenVibe account, so you can edit, renew or delete it later.', '/post')}
--<p class="muted">Posting is for people: an app or a service cannot post here.</p>`,
++<section class="post-preview" aria-labelledby="need">
++<h2 id="need">What you will need</h2>
++<ul class="need-list">
++<li><b>What it is</b><span>An apartment, a room, a house, commercial space, parking or storage, equipment — and a short title.</span></li>
++<li><b>The price</b><span>An amount, its currency and the period: a month, a week, a day, an hour or once.</span></li>
++<li><b>Where</b><span>The city and country, and a neighbourhood if you like. Never a street address: one is refused.</span></li>
++<li><b>How to reach you</b><span>A link or an email address. Only signed-in people see it.</span></li>
++</ul>
++<p class="muted small">A listing stays up for 30 days and you can renew it. Posting is for people: an app or a service cannot post here. <a href="/safety">The safety rules</a>.</p>
++</section>`,
}, 401);
return page(req, res, {
title: 'Post a listing',