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
Changes
@@@ -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); }@@@ -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',
@@ -37,19 +37,49 @@ form.inline { display: inline; }
@@ -37,19 +37,49 @@ form.inline { display: inline; }
.prose table { margin: .6rem 0; }
.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; }
.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; }
.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; }
.price { white-space: nowrap; }
.listing-facts { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; }
.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; }
.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, .report-list { list-style: none; padding: 0; }
.saved-list > li, .staff-item { padding: 8px 0; }
.saved-list > li, .staff-item { padding: 8px 0; }
.staff-item { border-bottom: 1px solid var(--border, #1f2d47); }
.staff-item { border-bottom: 1px solid var(--border, #1f2d47); }
@@ -58,10 +58,15 @@ function createPageRoutes(ctx) {
@@ -58,10 +58,15 @@ function createPageRoutes(ctx) {
const whereOf = (row) => [row.neighbourhood, row.city, row.country].filter(Boolean).join(', ');
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>`;
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>
<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>
<span class="listing-price">${priceOf(row)}</span>
</li>`;
</li>`;
@@ -147,8 +152,8 @@ ${notice(p && p.detail ? p.detail : 'You are over the limit for this.', 'warn')}
@@ -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 latest = await store.search(s, listings.normalizeFilters({}), { limit: HOME_LATEST });
const hero = showcase.hero({
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · ${TAGLINE}`,
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.`,
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)
actions: signedIn(req)
? [{ label: 'Post a listing', href: '/post', primary: true }, { label: 'Your listings', href: '/mine' }]
? [{ 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))}
@@ -358,7 +363,16 @@ ${listings.KINDS.map((k) => opt(k, listings.KIND_TEXT[k], v.kind))}
title: 'Post a listing',
title: 'Post a listing',
crumbs: [{ label: 'Home', href: '/' }, { label: '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')}
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);
}, 401);
return page(req, res, {
return page(req, res, {
title: 'Post a listing',
title: 'Post a listing',