first commit
This commit is contained in:
26
apps/demo/src/App.vue
Normal file
26
apps/demo/src/App.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { NativeNavigator, NativeRouterView } from '@native-vue-router/core'
|
||||
import { NativeTabBar, type NativeTabItem } from '@native-vue-router/preset-native'
|
||||
import { useRoute } from 'vue-router'
|
||||
import PwaUpdate from './components/PwaUpdate.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const siblingRoutes = ['/inbox', '/stories', '/profile']
|
||||
const showTabs = computed(() => Boolean(route.meta.tab))
|
||||
const tabs: NativeTabItem[] = [
|
||||
{ label: 'Inbox', to: '/inbox', icon: '◉' },
|
||||
{ label: 'Stories', to: '/stories', icon: '◎' },
|
||||
{ label: 'You', to: '/profile', icon: '◇' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-frame">
|
||||
<NativeNavigator :siblings="siblingRoutes">
|
||||
<NativeRouterView />
|
||||
</NativeNavigator>
|
||||
<NativeTabBar v-if="showTabs" :items="tabs" class="app-tabs" />
|
||||
<PwaUpdate />
|
||||
</div>
|
||||
</template>
|
||||
12
apps/demo/src/components/AppAvatar.vue
Normal file
12
apps/demo/src/components/AppAvatar.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { Person } from '../data'
|
||||
|
||||
defineProps<{ person: Person; size?: 'sm' | 'md' | 'lg' | 'xl' }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="avatar" :class="`avatar--${size ?? 'md'}`" :style="{ '--avatar-color': person.color }" :aria-label="person.name">
|
||||
<span>{{ person.name.split(' ').map((part) => part[0]).join('') }}</span>
|
||||
<i v-if="person.online" aria-label="Online" />
|
||||
</span>
|
||||
</template>
|
||||
19
apps/demo/src/components/AppHeader.vue
Normal file
19
apps/demo/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { NativeBackButton } from '@native-vue-router/preset-native'
|
||||
|
||||
withDefaults(defineProps<{ title: string; subtitle?: string; back?: boolean; large?: boolean }>(), {
|
||||
back: false,
|
||||
large: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header" :class="{ 'app-header--large': large }">
|
||||
<NativeBackButton v-if="back" />
|
||||
<div class="app-header__title">
|
||||
<p v-if="subtitle">{{ subtitle }}</p>
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
<div class="app-header__actions"><slot /></div>
|
||||
</header>
|
||||
</template>
|
||||
18
apps/demo/src/components/PwaUpdate.vue
Normal file
18
apps/demo/src/components/PwaUpdate.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { useRegisterSW } from 'virtual:pwa-register/vue'
|
||||
import { useNativeRouter } from '@native-vue-router/core'
|
||||
|
||||
const native = useNativeRouter()
|
||||
const { needRefresh, updateServiceWorker } = useRegisterSW()
|
||||
|
||||
function update() {
|
||||
if (!native.transaction.value) void updateServiceWorker(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside v-if="needRefresh" class="update-toast" role="status">
|
||||
<span>A fresh build is ready.</span>
|
||||
<button type="button" :disabled="Boolean(native.transaction.value)" @click="update">Update</button>
|
||||
</aside>
|
||||
</template>
|
||||
171
apps/demo/src/data.ts
Normal file
171
apps/demo/src/data.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { computed, reactive, readonly, ref } from 'vue'
|
||||
|
||||
export interface Person {
|
||||
id: string
|
||||
name: string
|
||||
handle: string
|
||||
color: string
|
||||
online: boolean
|
||||
bio: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string
|
||||
personId: string
|
||||
body: string
|
||||
sentAt: number
|
||||
mine: boolean
|
||||
status: 'sent' | 'delivered' | 'failed'
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string
|
||||
personId: string
|
||||
unread: number
|
||||
pinned?: boolean
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
const people: Person[] = [
|
||||
{ id: 'maya', name: 'Maya Chen', handle: '@mayac', color: '#ff7a8a', online: true, bio: 'Product designer · Sydney to everywhere.' },
|
||||
{ id: 'noah', name: 'Noah Williams', handle: '@noahw', color: '#4f9cff', online: true, bio: 'Film, tiny cameras, and very long walks.' },
|
||||
{ id: 'sofia', name: 'Sofia Rossi', handle: '@sofiar', color: '#9d67ff', online: false, bio: 'Making typefaces and better pasta.' },
|
||||
{ id: 'liam', name: 'Liam Park', handle: '@liamp', color: '#35c7a0', online: true, bio: 'Engineer. Climber. Questionable DJ.' },
|
||||
{ id: 'amara', name: 'Amara Okafor', handle: '@amarao', color: '#f4ad42', online: false, bio: 'Architecture and cities after dark.' },
|
||||
]
|
||||
|
||||
const timestamp = Date.now()
|
||||
const seedConversations: Conversation[] = [
|
||||
{
|
||||
id: 'maya', personId: 'maya', unread: 2, pinned: true,
|
||||
messages: [
|
||||
{ id: 'm1', personId: 'maya', body: 'That transition feels ridiculously smooth ✨', sentAt: timestamp - 840_000, mine: false, status: 'delivered' },
|
||||
{ id: 'm2', personId: 'maya', body: 'Try holding it halfway, then let go slowly.', sentAt: timestamp - 780_000, mine: false, status: 'delivered' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'noah', personId: 'noah', unread: 0,
|
||||
messages: [
|
||||
{ id: 'n1', personId: 'noah', body: 'Uploaded the photos from yesterday.', sentAt: timestamp - 4_200_000, mine: false, status: 'delivered' },
|
||||
{ id: 'n2', personId: 'noah', body: 'The grain is perfect.', sentAt: timestamp - 4_000_000, mine: true, status: 'delivered' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sofia', personId: 'sofia', unread: 1,
|
||||
messages: [{ id: 's1', personId: 'sofia', body: 'Coffee at the new place tomorrow?', sentAt: timestamp - 18_000_000, mine: false, status: 'delivered' }],
|
||||
},
|
||||
{
|
||||
id: 'liam', personId: 'liam', unread: 0,
|
||||
messages: [{ id: 'l1', personId: 'liam', body: 'The build is green. Ship it.', sentAt: timestamp - 86_000_000, mine: false, status: 'delivered' }],
|
||||
},
|
||||
{
|
||||
id: 'amara', personId: 'amara', unread: 0,
|
||||
messages: [{ id: 'a1', personId: 'amara', body: 'This city never really goes quiet.', sentAt: timestamp - 172_000_000, mine: false, status: 'delivered' }],
|
||||
},
|
||||
]
|
||||
|
||||
const conversations = ref<Conversation[]>(structuredClone(seedConversations))
|
||||
const ready = ref(false)
|
||||
const offline = ref(!navigator.onLine)
|
||||
const settings = reactive({ simulatedLatency: 180, simulateFailures: false })
|
||||
|
||||
function openDatabase() {
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open('native-vue-messenger', 1)
|
||||
request.onupgradeneeded = () => request.result.createObjectStore('state')
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function readStored() {
|
||||
const database = await openDatabase()
|
||||
return await new Promise<Conversation[] | undefined>((resolve, reject) => {
|
||||
const transaction = database.transaction('state', 'readonly')
|
||||
const request = transaction.objectStore('state').get('conversations')
|
||||
request.onsuccess = () => resolve(request.result as Conversation[] | undefined)
|
||||
request.onerror = () => reject(request.error)
|
||||
}).finally(() => database.close())
|
||||
}
|
||||
|
||||
async function persist() {
|
||||
try {
|
||||
const database = await openDatabase()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = database.transaction('state', 'readwrite')
|
||||
transaction.objectStore('state').put(structuredClone(conversations.value), 'conversations')
|
||||
transaction.oncomplete = () => resolve()
|
||||
transaction.onerror = () => reject(transaction.error)
|
||||
})
|
||||
database.close()
|
||||
} catch {
|
||||
// Private browsing and locked-down webviews can reject IndexedDB.
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
const stored = await readStored()
|
||||
if (stored?.length) conversations.value = stored
|
||||
else await persist()
|
||||
} finally {
|
||||
ready.value = true
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('online', () => { offline.value = false })
|
||||
window.addEventListener('offline', () => { offline.value = true })
|
||||
void initialize()
|
||||
|
||||
export function useDemoStore() {
|
||||
const conversationFor = (id: string) => computed(() => conversations.value.find((conversation) => conversation.id === id))
|
||||
const personFor = (id: string) => people.find((person) => person.id === id)
|
||||
const markRead = (id: string) => {
|
||||
const conversation = conversations.value.find((item) => item.id === id)
|
||||
if (conversation) conversation.unread = 0
|
||||
void persist()
|
||||
}
|
||||
const sendMessage = async (id: string, body: string) => {
|
||||
const conversation = conversations.value.find((item) => item.id === id)
|
||||
if (!conversation || !body.trim()) return false
|
||||
const message: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
personId: id,
|
||||
body: body.trim(),
|
||||
sentAt: Date.now(),
|
||||
mine: true,
|
||||
status: 'sent',
|
||||
}
|
||||
conversation.messages.push(message)
|
||||
await persist()
|
||||
await new Promise((resolve) => window.setTimeout(resolve, settings.simulatedLatency))
|
||||
message.status = settings.simulateFailures || offline.value ? 'failed' : 'delivered'
|
||||
await persist()
|
||||
return message.status === 'delivered'
|
||||
}
|
||||
const reset = async () => {
|
||||
conversations.value = structuredClone(seedConversations)
|
||||
await persist()
|
||||
}
|
||||
return {
|
||||
people: readonly(ref(people)),
|
||||
conversations: readonly(conversations),
|
||||
ready: readonly(ready),
|
||||
offline: readonly(offline),
|
||||
settings,
|
||||
conversationFor,
|
||||
personFor,
|
||||
markRead,
|
||||
sendMessage,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export function relativeTime(value: number) {
|
||||
const minutes = Math.floor((Date.now() - value) / 60_000)
|
||||
if (minutes < 1) return 'now'
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h`
|
||||
return `${Math.floor(hours / 24)}d`
|
||||
}
|
||||
25
apps/demo/src/main.ts
Normal file
25
apps/demo/src/main.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createNativeRouter } from '@native-vue-router/core'
|
||||
import { createCapacitorAdapter } from '@native-vue-router/capacitor'
|
||||
import { createElectronRendererAdapter } from '@native-vue-router/electron'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './style.css'
|
||||
|
||||
const isElectron = navigator.userAgent.toLowerCase().includes('electron')
|
||||
const platform = isElectron
|
||||
? createElectronRendererAdapter()
|
||||
: createCapacitorAdapter({ haptics: true, exitAtRoot: true })
|
||||
|
||||
const nativeRouter = createNativeRouter({
|
||||
router,
|
||||
cache: { maxInactive: 8 },
|
||||
platform,
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(nativeRouter)
|
||||
|
||||
await router.isReady()
|
||||
app.mount('#app')
|
||||
39
apps/demo/src/router.ts
Normal file
39
apps/demo/src/router.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createRouter, createWebHashHistory, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/inbox' },
|
||||
{
|
||||
path: '/inbox', name: 'inbox', component: () => import('./views/InboxView.vue'),
|
||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 0, siblingHistory: 'replace', gesture: 'full' } },
|
||||
},
|
||||
{
|
||||
path: '/stories', name: 'stories', component: () => import('./views/StoriesView.vue'),
|
||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 1, siblingHistory: 'replace', gesture: 'full' } },
|
||||
},
|
||||
{
|
||||
path: '/profile', name: 'profile', component: () => import('./views/ProfileView.vue'),
|
||||
meta: { tab: true, native: { siblingGroup: 'primary', siblingOrder: 2, siblingHistory: 'replace', gesture: 'full' } },
|
||||
},
|
||||
{
|
||||
path: '/chat/:id', name: 'chat', component: () => import('./views/ChatView.vue'),
|
||||
meta: { native: { presentation: 'push', parent: '/inbox', gesture: 'edge' } },
|
||||
},
|
||||
{
|
||||
path: '/chat/:id/details', name: 'chat-details', component: () => import('./views/ContactView.vue'),
|
||||
meta: { native: { presentation: 'push', parent: (route) => `/chat/${String(route.params.id)}`, gesture: 'edge' } },
|
||||
},
|
||||
{
|
||||
path: '/compose', name: 'compose', component: () => import('./views/ComposeView.vue'),
|
||||
meta: { native: { presentation: 'sheet', parent: '/inbox', gesture: 'full' } },
|
||||
},
|
||||
{
|
||||
path: '/settings', name: 'settings', component: () => import('./views/SettingsView.vue'),
|
||||
meta: { native: { presentation: 'push', parent: '/profile', gesture: 'edge' } },
|
||||
},
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: window.location.protocol === 'file:' ? createWebHashHistory() : createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
})
|
||||
260
apps/demo/src/style.css
Normal file
260
apps/demo/src/style.css
Normal file
@@ -0,0 +1,260 @@
|
||||
@import "@native-vue-router/core/style.css";
|
||||
@import "@native-vue-router/preset-native/style.css";
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-rounded, "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #f5f6fa;
|
||||
background: #050608;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--nvr-view-background: #0b0d12;
|
||||
--nvr-accent: #8b73ff;
|
||||
--line: rgba(255, 255, 255, .08);
|
||||
--muted: #898e9c;
|
||||
--surface: #13161d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||
button, input { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button { color: inherit; }
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
background:
|
||||
radial-gradient(circle at 50% -15%, rgba(124, 92, 255, .18), transparent 36%),
|
||||
#050608;
|
||||
}
|
||||
|
||||
.app-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
max-width: 740px;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
background: #0b0d12;
|
||||
box-shadow: 0 0 80px rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
.app-tabs {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
background:
|
||||
radial-gradient(circle at 88% 4%, rgba(124, 92, 255, .09), transparent 24%),
|
||||
#0b0d12;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.screen::-webkit-scrollbar { display: none; }
|
||||
.screen--tabs { padding-bottom: calc(76px + env(safe-area-inset-bottom)); }
|
||||
|
||||
.app-header {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
min-height: calc(58px + env(safe-area-inset-top));
|
||||
padding: env(safe-area-inset-top) 14px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(11, 13, 18, .82);
|
||||
backdrop-filter: blur(24px) saturate(1.45);
|
||||
}
|
||||
|
||||
.app-header--large {
|
||||
position: relative;
|
||||
align-items: end;
|
||||
min-height: calc(126px + env(safe-area-inset-top));
|
||||
padding: calc(24px + env(safe-area-inset-top)) 20px 16px;
|
||||
border-bottom: 0;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.app-header__title { min-width: 0; }
|
||||
.app-header__title p { margin: 0 0 3px; color: var(--muted); font-size: 12px; font-weight: 600; letter-spacing: .03em; }
|
||||
.app-header__title h1 { overflow: hidden; margin: 0; text-overflow: ellipsis; font-size: 17px; line-height: 1.2; white-space: nowrap; }
|
||||
.app-header--large .app-header__title h1 { font-size: clamp(32px, 8vw, 42px); letter-spacing: -.045em; }
|
||||
.app-header__actions { display: flex; align-items: center; justify-content: flex-end; }
|
||||
|
||||
.round-button,
|
||||
.avatar-button {
|
||||
display: grid;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255,255,255,.1);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,.07);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.round-button { font-size: 25px; font-weight: 300; }
|
||||
.avatar-button { border: 0; background: transparent; }
|
||||
|
||||
.content-stack { padding: 0 16px 24px; }
|
||||
.search-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 46px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgba(255,255,255,.06);
|
||||
border-radius: 15px;
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,.055);
|
||||
}
|
||||
.search-field input { min-width: 0; flex: 1; border: 0; outline: 0; color: #fff; background: transparent; }
|
||||
.search-field input::placeholder { color: #707582; }
|
||||
|
||||
.story-strip { display: flex; gap: 18px; padding: 22px 3px 20px; overflow-x: auto; scrollbar-width: none; }
|
||||
.story-strip::-webkit-scrollbar { display: none; }
|
||||
.story-person { display: flex; flex: 0 0 auto; flex-direction: column; align-items: center; gap: 7px; color: #c9ccd5; font-size: 11px; }
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
place-items: center;
|
||||
border: 2px solid color-mix(in srgb, var(--avatar-color) 65%, white 5%);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, color-mix(in srgb, var(--avatar-color) 84%, white), color-mix(in srgb, var(--avatar-color) 54%, #111));
|
||||
box-shadow: inset 0 0 22px rgba(255,255,255,.12), 0 8px 24px color-mix(in srgb, var(--avatar-color) 22%, transparent);
|
||||
font-size: 15px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.avatar i { position: absolute; right: -1px; bottom: 1px; width: 12px; height: 12px; border: 2px solid #0b0d12; border-radius: 50%; background: #34d399; }
|
||||
.avatar--sm { width: 38px; height: 38px; font-size: 11px; }
|
||||
.avatar--lg { width: 60px; height: 60px; }
|
||||
.avatar--xl { width: 102px; height: 102px; font-size: 27px; }
|
||||
|
||||
.section-heading { display: flex; align-items: baseline; justify-content: space-between; padding: 2px 4px 10px; }
|
||||
.section-heading h2 { margin: 0; font-size: 16px; }
|
||||
.section-heading span { color: #646a77; font-size: 11px; }
|
||||
.conversation-list { overflow: hidden; border: 1px solid rgba(255,255,255,.055); border-radius: 21px; background: rgba(255,255,255,.03); }
|
||||
.conversation-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 76px;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 11px 14px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.conversation-row:last-child { border-bottom: 0; }
|
||||
.conversation-row:active { background: rgba(255,255,255,.045); }
|
||||
.conversation-copy { min-width: 0; flex: 1; }
|
||||
.conversation-copy > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.conversation-copy strong { display: block; overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.conversation-copy time { color: #6f7480; font-size: 11px; }
|
||||
.conversation-copy p { overflow: hidden; margin: 5px 0 0; color: #858a97; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.unread-badge { display: grid; min-width: 20px; height: 20px; padding: 0 6px; place-items: center; border-radius: 10px; background: var(--nvr-accent); font-size: 11px; font-weight: 800; }
|
||||
.chevron { color: #515663; font-size: 24px; }
|
||||
|
||||
.story-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 0 16px; }
|
||||
.story-card { position: relative; display: flex; min-height: 240px; flex-direction: column; justify-content: flex-end; gap: 9px; overflow: hidden; padding: 16px; border-radius: 25px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.09); }
|
||||
.story-card__glow { position: absolute; inset: 0; background: linear-gradient(transparent 30%, rgba(0,0,0,.62)); }
|
||||
.story-card > *:not(.story-card__glow) { position: relative; z-index: 1; }
|
||||
.story-card strong { font-size: 13px; }
|
||||
.story-card p { margin: 2px 0 0; color: rgba(255,255,255,.7); font-size: 11px; }
|
||||
.story-card__mark { position: absolute !important; top: 18px; right: 18px; font-size: 28px; }
|
||||
.gesture-tip { margin: 22px auto; padding: 0 34px; color: #646a77; font-size: 12px; line-height: 1.5; text-align: center; }
|
||||
|
||||
.profile-card { margin: 0 16px 18px; padding: 28px 20px 22px; border: 1px solid var(--line); border-radius: 26px; background: linear-gradient(145deg, rgba(124,92,255,.16), rgba(255,255,255,.025)); text-align: center; }
|
||||
.profile-avatar { display: grid; width: 88px; height: 88px; margin: 0 auto 14px; place-items: center; border: 2px solid #9b88ff; border-radius: 30px; background: linear-gradient(145deg, #9b88ff, #4735a5); box-shadow: 0 18px 44px rgba(124,92,255,.26); font-size: 24px; font-weight: 800; transform: rotate(-3deg); }
|
||||
.profile-card h2 { margin: 0; font-size: 24px; }
|
||||
.profile-card > p { margin: 5px 0 22px; color: var(--muted); font-size: 13px; }
|
||||
.profile-stats { display: grid; grid-template-columns: repeat(3, 1fr); }
|
||||
.profile-stats div { display: flex; flex-direction: column; gap: 3px; border-right: 1px solid var(--line); }
|
||||
.profile-stats div:last-child { border: 0; }
|
||||
.profile-stats strong { font-size: 17px; }
|
||||
.profile-stats span { color: var(--muted); font-size: 11px; }
|
||||
|
||||
.settings-list, .settings-group { overflow: hidden; margin: 0 16px 18px; border: 1px solid var(--line); border-radius: 20px; background: rgba(255,255,255,.03); }
|
||||
.settings-list > a, .settings-list > button { display: grid; width: 100%; min-height: 58px; grid-template-columns: 30px 1fr auto; align-items: center; padding: 0 15px; border: 0; border-bottom: 1px solid var(--line); color: inherit; background: transparent; text-align: left; text-decoration: none; }
|
||||
.settings-list > :last-child { border-bottom: 0; }
|
||||
.settings-list strong { font-size: 14px; }
|
||||
.settings-list i { color: var(--muted); font-size: 12px; font-style: normal; }
|
||||
.settings-list .danger { color: #ff6c76; }
|
||||
|
||||
.chat-screen { display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; }
|
||||
.message-list { display: flex; min-height: 0; flex-direction: column; gap: 8px; overflow-y: auto; padding: 18px 14px; overscroll-behavior: contain; }
|
||||
.message-day { align-self: center; margin-bottom: 9px; padding: 5px 10px; border-radius: 12px; color: #737986; background: rgba(255,255,255,.04); font-size: 10px; font-weight: 700; }
|
||||
.message { max-width: min(78%, 480px); align-self: flex-start; }
|
||||
.message p { margin: 0; padding: 10px 13px; border-radius: 18px 18px 18px 5px; background: #1a1e27; font-size: 14px; line-height: 1.42; }
|
||||
.message footer { display: flex; gap: 6px; margin: 4px 5px 0; color: #656b78; font-size: 9px; }
|
||||
.message--mine { align-self: flex-end; }
|
||||
.message--mine p { border-radius: 18px 18px 5px 18px; background: linear-gradient(145deg, #8268ff, #6247d7); }
|
||||
.message--mine footer { justify-content: flex-end; }
|
||||
.typing-pill { display: flex; width: 50px; gap: 4px; padding: 12px 13px; border-radius: 18px 18px 18px 5px; background: #1a1e27; }
|
||||
.typing-pill i { width: 5px; height: 5px; border-radius: 50%; background: #777d89; animation: typing 1s infinite alternate; }
|
||||
.typing-pill i:nth-child(2) { animation-delay: .2s; }.typing-pill i:nth-child(3) { animation-delay: .4s; }
|
||||
@keyframes typing { to { opacity: .25; transform: translateY(-3px); } }
|
||||
.composer { display: grid; grid-template-columns: 38px 1fr 38px; gap: 8px; padding: 10px max(12px, env(safe-area-inset-right)) calc(10px + env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); border-top: 1px solid var(--line); background: rgba(11,13,18,.92); backdrop-filter: blur(24px); }
|
||||
.composer input { min-width: 0; border: 1px solid var(--line); border-radius: 20px; outline: 0; padding: 0 15px; color: #fff; background: rgba(255,255,255,.055); }
|
||||
.composer button { border: 0; border-radius: 50%; background: rgba(255,255,255,.07); font-size: 21px; }
|
||||
.composer .send-button { background: var(--nvr-accent); font-weight: 700; }
|
||||
.composer .send-button:disabled { opacity: .35; }
|
||||
|
||||
.contact-hero { padding: 36px 24px 20px; text-align: center; }
|
||||
.contact-hero h1 { margin: 15px 0 2px; font-size: 26px; }
|
||||
.contact-hero p { max-width: 340px; margin: 5px auto; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||
.contact-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 0 16px 22px; }
|
||||
.contact-actions button { display: flex; min-height: 72px; flex-direction: column; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--line); border-radius: 18px; background: rgba(255,255,255,.035); font-size: 11px; }
|
||||
.contact-actions span { color: #9b88ff; font-size: 21px; }
|
||||
|
||||
.sheet-screen { border-radius: 22px 22px 0 0; }
|
||||
.sheet-handle { position: sticky; z-index: 12; top: 8px; width: 38px; height: 5px; margin: 8px auto 0; border-radius: 4px; background: #4e5360; }
|
||||
.sheet-header { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 16px; }
|
||||
.sheet-header h1 { margin: 0; font-size: 16px; }
|
||||
.sheet-header button { justify-self: start; border: 0; color: #9b88ff; background: transparent; }
|
||||
.compose-search { margin: 0 16px 14px; }
|
||||
|
||||
.lab-intro { display: flex; align-items: center; gap: 16px; margin: 20px 16px; padding: 18px; border: 1px solid rgba(124,92,255,.2); border-radius: 20px; background: rgba(124,92,255,.1); }
|
||||
.lab-intro > span { display: grid; width: 54px; height: 54px; place-items: center; border-radius: 17px; background: #7c5cff; font-size: 20px; font-weight: 800; }
|
||||
.lab-intro strong { font-size: 14px; }.lab-intro p { margin: 4px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.settings-group { padding: 8px 0; }
|
||||
.settings-group h2 { margin: 8px 15px; color: #737986; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }
|
||||
.settings-group > label, .settings-group > div { display: flex; min-height: 62px; align-items: center; justify-content: space-between; gap: 15px; padding: 10px 15px; border-top: 1px solid var(--line); }
|
||||
.settings-group > p { margin: 0; padding: 12px 15px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||||
.settings-group label span, .settings-group div span { display: flex; flex-direction: column; gap: 3px; }
|
||||
.settings-group strong { font-size: 13px; }.settings-group small { color: var(--muted); }
|
||||
.settings-group input[type="range"] { width: 120px; accent-color: var(--nvr-accent); }
|
||||
.settings-group input[type="checkbox"] { width: 42px; height: 24px; accent-color: var(--nvr-accent); }
|
||||
.settings-group b { color: #3dd9aa; font-size: 12px; }.settings-group b.offline { color: #ff7a84; }
|
||||
.reset-button { display: block; width: calc(100% - 32px); min-height: 48px; margin: 0 16px 30px; border: 1px solid rgba(255,108,118,.2); border-radius: 15px; color: #ff7a84; background: rgba(255,108,118,.07); }
|
||||
.empty-state { display: grid; place-items: center; }
|
||||
.update-toast { position: absolute; z-index: 50; right: 14px; bottom: calc(82px + env(safe-area-inset-bottom)); left: 14px; display: flex; align-items: center; justify-content: space-between; padding: 12px 14px; border: 1px solid var(--line); border-radius: 15px; background: rgba(28,31,40,.96); box-shadow: 0 18px 50px rgba(0,0,0,.4); font-size: 12px; }
|
||||
.update-toast button { border: 0; color: #a998ff; background: transparent; font-weight: 700; }
|
||||
|
||||
@media (min-width: 741px) {
|
||||
.app-frame { height: min(920px, calc(100dvh - 28px)); margin-top: 14px; border: 1px solid rgba(255,255,255,.08); border-radius: 30px; }
|
||||
.story-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.story-card { min-height: 300px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
59
apps/demo/src/views/ChatView.vue
Normal file
59
apps/demo/src/views/ChatView.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { NativeLink } from '@native-vue-router/core'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppAvatar from '../components/AppAvatar.vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { relativeTime, useDemoStore } from '../data'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useDemoStore()
|
||||
const id = computed(() => String(route.params.id))
|
||||
const conversation = computed(() => store.conversations.value.find((item) => item.id === id.value))
|
||||
const person = computed(() => store.personFor(id.value))
|
||||
const draft = ref('')
|
||||
const list = ref<HTMLElement | null>(null)
|
||||
|
||||
async function send() {
|
||||
const body = draft.value
|
||||
if (!body.trim()) return
|
||||
draft.value = ''
|
||||
void store.sendMessage(id.value, body)
|
||||
await nextTick()
|
||||
list.value?.scrollTo({ top: list.value.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.markRead(id.value)
|
||||
list.value?.scrollTo({ top: list.value.scrollHeight })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="conversation && person" class="screen chat-screen">
|
||||
<AppHeader :title="person.name" :subtitle="person.online ? 'Online now' : person.handle" back>
|
||||
<NativeLink :to="`/chat/${id}/details`" class="avatar-button" aria-label="Conversation details">
|
||||
<AppAvatar :person="person" size="sm" />
|
||||
</NativeLink>
|
||||
</AppHeader>
|
||||
|
||||
<div ref="list" class="message-list">
|
||||
<div class="message-day">Today</div>
|
||||
<article v-for="message in conversation.messages" :key="message.id" class="message" :class="{ 'message--mine': message.mine }">
|
||||
<p>{{ message.body }}</p>
|
||||
<footer>
|
||||
<time>{{ relativeTime(message.sentAt) }}</time>
|
||||
<span v-if="message.mine">{{ message.status === 'failed' ? 'Tap to retry' : message.status }}</span>
|
||||
</footer>
|
||||
</article>
|
||||
<div v-if="person.online" class="typing-pill"><i /><i /><i /></div>
|
||||
</div>
|
||||
|
||||
<form class="composer" @submit.prevent="send">
|
||||
<button type="button" aria-label="Add attachment">+</button>
|
||||
<input v-model="draft" aria-label="Message" :placeholder="`Message ${person.name.split(' ')[0]}`" />
|
||||
<button class="send-button" type="submit" :disabled="!draft.trim()" aria-label="Send">↑</button>
|
||||
</form>
|
||||
</main>
|
||||
<main v-else class="screen empty-state"><h1>Conversation not found</h1></main>
|
||||
</template>
|
||||
37
apps/demo/src/views/ComposeView.vue
Normal file
37
apps/demo/src/views/ComposeView.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { NativeDismissGesture, useNativeRouter } from '@native-vue-router/core'
|
||||
import AppAvatar from '../components/AppAvatar.vue'
|
||||
import { useDemoStore } from '../data'
|
||||
|
||||
const native = useNativeRouter()
|
||||
const store = useDemoStore()
|
||||
const query = ref('')
|
||||
|
||||
async function choose(id: string) {
|
||||
await native.cancelInteractive()
|
||||
await native.replace(`/chat/${id}`, { presentation: 'push' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NativeDismissGesture as="main" class="screen sheet-screen">
|
||||
<div class="sheet-handle" aria-hidden="true" />
|
||||
<header class="sheet-header">
|
||||
<button type="button" @click="native.dismiss()">Cancel</button>
|
||||
<h1>New message</h1>
|
||||
<span />
|
||||
</header>
|
||||
<label class="search-field compose-search">
|
||||
<span>To:</span>
|
||||
<input v-model="query" autofocus placeholder="Search people" />
|
||||
</label>
|
||||
<div class="conversation-list">
|
||||
<button v-for="person in store.people.value.filter((item) => item.name.toLowerCase().includes(query.toLowerCase()))" :key="person.id" class="conversation-row" @click="choose(person.id)">
|
||||
<AppAvatar :person="person" />
|
||||
<div class="conversation-copy"><strong>{{ person.name }}</strong><p>{{ person.handle }}</p></div>
|
||||
<span class="chevron">›</span>
|
||||
</button>
|
||||
</div>
|
||||
</NativeDismissGesture>
|
||||
</template>
|
||||
33
apps/demo/src/views/ContactView.vue
Normal file
33
apps/demo/src/views/ContactView.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppAvatar from '../components/AppAvatar.vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { useDemoStore } from '../data'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useDemoStore()
|
||||
const person = computed(() => store.personFor(String(route.params.id)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="person" class="screen">
|
||||
<AppHeader title="Details" back />
|
||||
<section class="contact-hero">
|
||||
<AppAvatar :person="person" size="xl" />
|
||||
<h1>{{ person.name }}</h1>
|
||||
<p>{{ person.handle }}</p>
|
||||
<p>{{ person.bio }}</p>
|
||||
</section>
|
||||
<section class="contact-actions">
|
||||
<button><span>◌</span>Audio</button>
|
||||
<button><span>▣</span>Video</button>
|
||||
<button><span>⌕</span>Search</button>
|
||||
</section>
|
||||
<section class="settings-list">
|
||||
<button><span>▧</span><strong>Shared media</strong><i>24</i></button>
|
||||
<button><span>♧</span><strong>Mute notifications</strong><i>Off</i></button>
|
||||
<button class="danger"><span>⊘</span><strong>Block contact</strong><i /></button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
66
apps/demo/src/views/InboxView.vue
Normal file
66
apps/demo/src/views/InboxView.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { NativeGestureLink, useNativeRouter } from '@native-vue-router/core'
|
||||
import AppAvatar from '../components/AppAvatar.vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { relativeTime, useDemoStore } from '../data'
|
||||
|
||||
const store = useDemoStore()
|
||||
const native = useNativeRouter()
|
||||
const query = ref('')
|
||||
const filtered = computed(() => store.conversations.value.filter((conversation) => {
|
||||
const person = store.personFor(conversation.personId)
|
||||
return person?.name.toLowerCase().includes(query.value.toLowerCase())
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="screen screen--tabs">
|
||||
<AppHeader title="Messages" subtitle="Thursday, 16 July" large>
|
||||
<button class="round-button" type="button" aria-label="Compose" @click="native.present('/compose', 'sheet')">+</button>
|
||||
</AppHeader>
|
||||
|
||||
<section class="content-stack">
|
||||
<label class="search-field">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input v-model="query" type="search" placeholder="Search conversations" />
|
||||
</label>
|
||||
|
||||
<div class="story-strip" aria-label="Online friends">
|
||||
<div v-for="person in store.people.value.filter((item) => item.online)" :key="person.id" class="story-person">
|
||||
<AppAvatar :person="person" size="lg" />
|
||||
<span>{{ person.name.split(' ')[0] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-heading">
|
||||
<h2>Recent</h2>
|
||||
<span>Drag a conversation left</span>
|
||||
</div>
|
||||
|
||||
<div class="conversation-list">
|
||||
<NativeGestureLink
|
||||
v-for="conversation in filtered"
|
||||
:key="conversation.id"
|
||||
:to="`/chat/${conversation.id}`"
|
||||
presentation="reveal"
|
||||
direction="left"
|
||||
class="conversation-row"
|
||||
>
|
||||
<template v-if="store.personFor(conversation.personId)" >
|
||||
<AppAvatar :person="store.personFor(conversation.personId)!" size="md" />
|
||||
<div class="conversation-copy">
|
||||
<div>
|
||||
<strong>{{ store.personFor(conversation.personId)?.name }}</strong>
|
||||
<time>{{ relativeTime(conversation.messages.at(-1)?.sentAt ?? 0) }}</time>
|
||||
</div>
|
||||
<p>{{ conversation.messages.at(-1)?.body }}</p>
|
||||
</div>
|
||||
<span v-if="conversation.unread" class="unread-badge">{{ conversation.unread }}</span>
|
||||
<span v-else class="chevron" aria-hidden="true">›</span>
|
||||
</template>
|
||||
</NativeGestureLink>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
25
apps/demo/src/views/ProfileView.vue
Normal file
25
apps/demo/src/views/ProfileView.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { NativeLink } from '@native-vue-router/core'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="screen screen--tabs">
|
||||
<AppHeader title="You" subtitle="Your space" large />
|
||||
<section class="profile-card">
|
||||
<div class="profile-avatar">HV</div>
|
||||
<h2>Harvey</h2>
|
||||
<p>@harvmaster · Sydney</p>
|
||||
<div class="profile-stats">
|
||||
<div><strong>28</strong><span>Friends</span></div>
|
||||
<div><strong>164</strong><span>Moments</span></div>
|
||||
<div><strong>12</strong><span>Groups</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-list">
|
||||
<NativeLink to="/settings"><span>⚙︎</span><strong>Navigation lab</strong><i>›</i></NativeLink>
|
||||
<a href="https://github.com" target="_blank" rel="noreferrer"><span>⌘</span><strong>Project source</strong><i>↗</i></a>
|
||||
<button type="button"><span>◐</span><strong>Appearance</strong><i>System</i></button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
27
apps/demo/src/views/SettingsView.vue
Normal file
27
apps/demo/src/views/SettingsView.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { useDemoStore } from '../data'
|
||||
|
||||
const store = useDemoStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="screen">
|
||||
<AppHeader title="Navigation lab" subtitle="Runtime controls" back />
|
||||
<section class="lab-intro">
|
||||
<span>60</span>
|
||||
<div><strong>FPS baseline</strong><p>Interactive layers use transform and opacity only.</p></div>
|
||||
</section>
|
||||
<section class="settings-group">
|
||||
<h2>Simulation</h2>
|
||||
<label><span><strong>Network latency</strong><small>{{ store.settings.simulatedLatency }} ms</small></span><input v-model.number="store.settings.simulatedLatency" type="range" min="0" max="1200" step="20" /></label>
|
||||
<label><span><strong>Force message failures</strong><small>Test optimistic UI</small></span><input v-model="store.settings.simulateFailures" type="checkbox" /></label>
|
||||
<div><span><strong>Connection</strong><small>Browser network state</small></span><b :class="{ offline: store.offline.value }">{{ store.offline.value ? 'Offline' : 'Online' }}</b></div>
|
||||
</section>
|
||||
<section class="settings-group">
|
||||
<h2>Gesture recipes</h2>
|
||||
<p>Hold an edge-back gesture at any progress, swipe between primary routes, drag a conversation, or open the compose sheet.</p>
|
||||
</section>
|
||||
<button class="reset-button" type="button" @click="store.reset">Reset offline demo data</button>
|
||||
</main>
|
||||
</template>
|
||||
31
apps/demo/src/views/StoriesView.vue
Normal file
31
apps/demo/src/views/StoriesView.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import AppAvatar from '../components/AppAvatar.vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import { useDemoStore } from '../data'
|
||||
|
||||
const store = useDemoStore()
|
||||
const gradients = [
|
||||
'linear-gradient(155deg, #f4a261, #d64e7d 48%, #5427a8)',
|
||||
'linear-gradient(155deg, #44c6e9, #4378db 48%, #1b245d)',
|
||||
'linear-gradient(155deg, #68d391, #2a9d8f 48%, #173e48)',
|
||||
'linear-gradient(155deg, #b794f4, #805ad5 48%, #322659)',
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="screen screen--tabs">
|
||||
<AppHeader title="Stories" subtitle="Moments from your circle" large />
|
||||
<section class="story-grid">
|
||||
<article v-for="(person, index) in store.people.value.slice(0, 4)" :key="person.id" class="story-card" :style="{ background: gradients[index] }">
|
||||
<div class="story-card__glow" />
|
||||
<AppAvatar :person="person" size="sm" />
|
||||
<div>
|
||||
<strong>{{ person.name }}</strong>
|
||||
<p>{{ index % 2 ? '2 hours ago' : 'Just now' }}</p>
|
||||
</div>
|
||||
<span class="story-card__mark">{{ ['✦', '◌', '△', '◇'][index] }}</span>
|
||||
</article>
|
||||
</section>
|
||||
<p class="gesture-tip">Swipe horizontally anywhere to move between primary routes.</p>
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user