@vue-lynx-example/hackernews 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +18 -0
- package/dist/main.lynx.bundle +0 -0
- package/dist/main.web.bundle +1 -0
- package/lynx.config.ts +39 -0
- package/package.json +41 -0
- package/src/App.css +16 -0
- package/src/App.vue +97 -0
- package/src/api.ts +82 -0
- package/src/assets/lynx-logo.png +0 -0
- package/src/components/Comment.vue +78 -0
- package/src/components/NavLink.vue +35 -0
- package/src/components/NewsItem.vue +80 -0
- package/src/components/Spinner.vue +30 -0
- package/src/index.ts +15 -0
- package/src/pages/FeedList.vue +161 -0
- package/src/pages/ItemPage.vue +109 -0
- package/src/pages/UserPage.vue +89 -0
- package/src/router.ts +37 -0
- package/src/shims-vue.d.ts +5 -0
- package/src/utils.ts +26 -0
- package/tsconfig.json +10 -0
package/src/App.vue
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RouterView } from 'vue-router';
|
|
3
|
+
import { validFeeds } from './api';
|
|
4
|
+
import NavLink from './components/NavLink.vue';
|
|
5
|
+
import logoUrl from './assets/lynx-logo.png';
|
|
6
|
+
|
|
7
|
+
import './App.css';
|
|
8
|
+
|
|
9
|
+
const feedKeys = Object.keys(validFeeds);
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<view class="w-full h-full bg-hn-bg flex flex-col">
|
|
14
|
+
<!-- Header: 55px, matching reference -->
|
|
15
|
+
<view
|
|
16
|
+
class="bg-hn-green flex flex-col"
|
|
17
|
+
:style="{ height: '55px' }"
|
|
18
|
+
>
|
|
19
|
+
<scroll-view
|
|
20
|
+
scroll-x
|
|
21
|
+
class="flex-1"
|
|
22
|
+
:style="{ height: '100%' }"
|
|
23
|
+
:content-container-style="{ display: 'flex', flexDirection: 'row', alignItems: 'center' }"
|
|
24
|
+
>
|
|
25
|
+
<!-- Logo: Lynx logo with thin white border (YC-style) -->
|
|
26
|
+
<view
|
|
27
|
+
:style="{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginLeft: '15px', marginRight: '12px' }"
|
|
28
|
+
>
|
|
29
|
+
<view
|
|
30
|
+
class="flex-shrink-0"
|
|
31
|
+
:style="{ width: '26px', height: '26px', borderWidth: '1px', borderColor: '#fff', borderRadius: '0px', display: 'flex', alignItems: 'center', justifyContent: 'center' }"
|
|
32
|
+
>
|
|
33
|
+
<image
|
|
34
|
+
:src="logoUrl"
|
|
35
|
+
:style="{ width: '24px', height: '24px' }"
|
|
36
|
+
resize="cover"
|
|
37
|
+
/>
|
|
38
|
+
</view>
|
|
39
|
+
</view>
|
|
40
|
+
|
|
41
|
+
<!-- Feed nav links -->
|
|
42
|
+
<NavLink
|
|
43
|
+
v-for="key in feedKeys"
|
|
44
|
+
:key="key"
|
|
45
|
+
:to="`/${key}`"
|
|
46
|
+
:label="validFeeds[key].title"
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
<!-- Built with VueLynx -->
|
|
50
|
+
<view
|
|
51
|
+
:style="{
|
|
52
|
+
display: 'flex',
|
|
53
|
+
alignItems: 'center',
|
|
54
|
+
justifyContent: 'center',
|
|
55
|
+
marginLeft: 'auto',
|
|
56
|
+
marginRight: '15px',
|
|
57
|
+
}"
|
|
58
|
+
>
|
|
59
|
+
<text
|
|
60
|
+
:style="{
|
|
61
|
+
color: '#fff',
|
|
62
|
+
fontSize: '14px',
|
|
63
|
+
fontWeight: '300',
|
|
64
|
+
letterSpacing: '0.075em',
|
|
65
|
+
whiteSpace: 'nowrap',
|
|
66
|
+
flexShrink: 0,
|
|
67
|
+
}"
|
|
68
|
+
>
|
|
69
|
+
Built with VueLynx
|
|
70
|
+
</text>
|
|
71
|
+
</view>
|
|
72
|
+
</scroll-view>
|
|
73
|
+
</view>
|
|
74
|
+
|
|
75
|
+
<!-- Route content: fade on route type change, NOT on pagination -->
|
|
76
|
+
<RouterView v-slot="{ Component, route }">
|
|
77
|
+
<Transition name="fade" mode="out-in" :duration="200">
|
|
78
|
+
<component
|
|
79
|
+
:is="Component"
|
|
80
|
+
:key="(route.params.feed as string) || route.name"
|
|
81
|
+
/>
|
|
82
|
+
</Transition>
|
|
83
|
+
</RouterView>
|
|
84
|
+
</view>
|
|
85
|
+
</template>
|
|
86
|
+
|
|
87
|
+
<style>
|
|
88
|
+
/* Fade: for switching between route types (feed→item, feed→user, news→ask) */
|
|
89
|
+
.fade-enter-active,
|
|
90
|
+
.fade-leave-active {
|
|
91
|
+
transition: opacity 200ms ease;
|
|
92
|
+
}
|
|
93
|
+
.fade-enter-from,
|
|
94
|
+
.fade-leave-to {
|
|
95
|
+
opacity: 0;
|
|
96
|
+
}
|
|
97
|
+
</style>
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const _fetch: typeof fetch = globalThis.fetch ?? fetch;
|
|
2
|
+
|
|
3
|
+
const BASE_URL = 'https://api.hackerwebapp.com';
|
|
4
|
+
|
|
5
|
+
export interface FeedItem {
|
|
6
|
+
id: number;
|
|
7
|
+
title: string;
|
|
8
|
+
url: string;
|
|
9
|
+
user: string;
|
|
10
|
+
points: number;
|
|
11
|
+
time_ago: string;
|
|
12
|
+
comments_count: number;
|
|
13
|
+
type: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ItemDetail {
|
|
17
|
+
id: number;
|
|
18
|
+
title: string;
|
|
19
|
+
url: string;
|
|
20
|
+
user: string;
|
|
21
|
+
points: number;
|
|
22
|
+
time_ago: string;
|
|
23
|
+
comments_count: number;
|
|
24
|
+
comments: CommentData[];
|
|
25
|
+
content: string;
|
|
26
|
+
type: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CommentData {
|
|
30
|
+
id: number;
|
|
31
|
+
user: string;
|
|
32
|
+
time_ago: string;
|
|
33
|
+
content: string;
|
|
34
|
+
comments: CommentData[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UserData {
|
|
38
|
+
id: string;
|
|
39
|
+
created: string;
|
|
40
|
+
karma: number;
|
|
41
|
+
about: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const validFeeds: Record<string, { title: string; pages: number }> = {
|
|
45
|
+
news: { title: 'News', pages: 10 },
|
|
46
|
+
newest: { title: 'Newest', pages: 12 },
|
|
47
|
+
ask: { title: 'Ask', pages: 2 },
|
|
48
|
+
show: { title: 'Show', pages: 2 },
|
|
49
|
+
jobs: { title: 'Jobs', pages: 1 },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export async function fetchFeed(
|
|
53
|
+
feed: string,
|
|
54
|
+
page: number,
|
|
55
|
+
): Promise<FeedItem[]> {
|
|
56
|
+
const res = await _fetch(`${BASE_URL}/${feed}?page=${page}`);
|
|
57
|
+
if (!res.ok) throw new Error(`Failed to fetch ${feed} page ${page}`);
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function fetchItem(id: number): Promise<ItemDetail> {
|
|
62
|
+
const res = await _fetch(`${BASE_URL}/item/${id}`);
|
|
63
|
+
if (!res.ok) throw new Error(`Failed to fetch item ${id}`);
|
|
64
|
+
return res.json();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function fetchUser(id: string): Promise<UserData> {
|
|
68
|
+
// The hackerwebapp API doesn't have a working user endpoint,
|
|
69
|
+
// so we use the official HN Firebase API and normalize the response.
|
|
70
|
+
const res = await _fetch(
|
|
71
|
+
`https://hacker-news.firebaseio.com/v0/user/${id}.json`,
|
|
72
|
+
);
|
|
73
|
+
if (!res.ok) throw new Error(`Failed to fetch user ${id}`);
|
|
74
|
+
const data = await res.json();
|
|
75
|
+
if (!data) throw new Error(`User ${id} not found`);
|
|
76
|
+
return {
|
|
77
|
+
id: data.id,
|
|
78
|
+
created: new Date(data.created * 1000).toLocaleDateString(),
|
|
79
|
+
karma: data.karma,
|
|
80
|
+
about: data.about || '',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue-lynx';
|
|
3
|
+
import { useRouter } from 'vue-router';
|
|
4
|
+
import type { CommentData } from '../api';
|
|
5
|
+
import { pluralize, stripHtml } from '../utils';
|
|
6
|
+
|
|
7
|
+
defineProps<{
|
|
8
|
+
comment: CommentData;
|
|
9
|
+
depth?: number;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
const router = useRouter();
|
|
13
|
+
const open = ref(true);
|
|
14
|
+
|
|
15
|
+
function goToUser(userId: string) {
|
|
16
|
+
router.push(`/user/${userId}`);
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<view
|
|
22
|
+
v-if="comment && comment.user"
|
|
23
|
+
class="flex flex-col border-t border-hn-border"
|
|
24
|
+
:style="{ paddingLeft: `${(depth ?? 0) * 24}px` }"
|
|
25
|
+
>
|
|
26
|
+
<!-- Comment header -->
|
|
27
|
+
<view class="flex flex-row" :style="{ gap: '6px', paddingTop: '1em' }">
|
|
28
|
+
<text
|
|
29
|
+
:style="{ color: '#222', fontSize: '0.9em', textDecorationLine: 'underline' }"
|
|
30
|
+
@tap="goToUser(comment.user)"
|
|
31
|
+
>
|
|
32
|
+
{{ comment.user }}
|
|
33
|
+
</text>
|
|
34
|
+
<text :style="{ color: '#595959', fontSize: '0.9em' }">
|
|
35
|
+
{{ comment.time_ago }}
|
|
36
|
+
</text>
|
|
37
|
+
</view>
|
|
38
|
+
|
|
39
|
+
<!-- Comment body -->
|
|
40
|
+
<view :style="{ paddingTop: '0.5em', paddingBottom: '0.5em' }">
|
|
41
|
+
<text :style="{ color: '#2e495e', fontSize: '0.9em', lineHeight: '1.5em' }">
|
|
42
|
+
{{ stripHtml(comment.content) }}
|
|
43
|
+
</text>
|
|
44
|
+
</view>
|
|
45
|
+
|
|
46
|
+
<!-- Toggle children -->
|
|
47
|
+
<view v-if="comment.comments && comment.comments.length">
|
|
48
|
+
<view
|
|
49
|
+
:style="{
|
|
50
|
+
backgroundColor: open ? 'transparent' : '#fffbf2',
|
|
51
|
+
borderRadius: '4px',
|
|
52
|
+
padding: open ? '0' : '0.3em 0.5em',
|
|
53
|
+
alignSelf: 'flex-start',
|
|
54
|
+
marginBottom: open ? '0' : '0.5em',
|
|
55
|
+
}"
|
|
56
|
+
@tap="open = !open"
|
|
57
|
+
>
|
|
58
|
+
<text :style="{ color: '#222', fontSize: '0.9em' }">
|
|
59
|
+
{{
|
|
60
|
+
open
|
|
61
|
+
? '[-]'
|
|
62
|
+
: '[+] ' + pluralize(comment.comments.length, 'reply', 'replies') + ' collapsed'
|
|
63
|
+
}}
|
|
64
|
+
</text>
|
|
65
|
+
</view>
|
|
66
|
+
</view>
|
|
67
|
+
|
|
68
|
+
<!-- Nested comments -->
|
|
69
|
+
<template v-if="open && comment.comments">
|
|
70
|
+
<Comment
|
|
71
|
+
v-for="child in comment.comments"
|
|
72
|
+
:key="child.id"
|
|
73
|
+
:comment="child"
|
|
74
|
+
:depth="(depth ?? 0) + 1"
|
|
75
|
+
/>
|
|
76
|
+
</template>
|
|
77
|
+
</view>
|
|
78
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { RouterLink } from 'vue-router';
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
to: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}>();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<RouterLink :to="to" custom v-slot="{ navigate, isActive }">
|
|
12
|
+
<view
|
|
13
|
+
:style="{
|
|
14
|
+
display: 'flex',
|
|
15
|
+
alignItems: 'center',
|
|
16
|
+
justifyContent: 'center',
|
|
17
|
+
}"
|
|
18
|
+
>
|
|
19
|
+
<text
|
|
20
|
+
@tap="navigate"
|
|
21
|
+
:style="{
|
|
22
|
+
color: '#fff',
|
|
23
|
+
fontSize: '15px',
|
|
24
|
+
fontWeight: isActive ? '600' : '300',
|
|
25
|
+
letterSpacing: '0.075em',
|
|
26
|
+
lineHeight: '24px',
|
|
27
|
+
marginRight: '1em',
|
|
28
|
+
whiteSpace: 'nowrap',
|
|
29
|
+
}"
|
|
30
|
+
>
|
|
31
|
+
{{ label }}
|
|
32
|
+
</text>
|
|
33
|
+
</view>
|
|
34
|
+
</RouterLink>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useRouter } from 'vue-router';
|
|
3
|
+
import type { FeedItem } from '../api';
|
|
4
|
+
import { toHost, isAbsoluteUrl } from '../utils';
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
item: FeedItem;
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const host = toHost(props.item.url);
|
|
12
|
+
|
|
13
|
+
function goToItem() {
|
|
14
|
+
router.push(`/item/${props.item.id}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function goToUser() {
|
|
18
|
+
router.push(`/user/${props.item.user}`);
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<view
|
|
24
|
+
class="bg-hn-card flex flex-row border-b border-hn-border"
|
|
25
|
+
:style="{ padding: '20px 16px 20px 0' }"
|
|
26
|
+
>
|
|
27
|
+
<!-- Score: 80px wide, centered -->
|
|
28
|
+
<view :style="{ width: '80px', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }">
|
|
29
|
+
<text :style="{ color: '#3eaf7c', fontSize: '1.1em', fontWeight: '700' }">
|
|
30
|
+
{{ item.points }}
|
|
31
|
+
</text>
|
|
32
|
+
</view>
|
|
33
|
+
|
|
34
|
+
<!-- Content -->
|
|
35
|
+
<view class="flex flex-col flex-1">
|
|
36
|
+
<!-- Title -->
|
|
37
|
+
<text
|
|
38
|
+
:style="{ color: '#2e495e', fontSize: '15px', lineHeight: '20px' }"
|
|
39
|
+
@tap="goToItem"
|
|
40
|
+
>
|
|
41
|
+
{{ item.title }}
|
|
42
|
+
</text>
|
|
43
|
+
|
|
44
|
+
<!-- Host -->
|
|
45
|
+
<text
|
|
46
|
+
v-if="isAbsoluteUrl(item.url)"
|
|
47
|
+
:style="{ color: '#595959', fontSize: '0.85em', marginTop: '2px' }"
|
|
48
|
+
>
|
|
49
|
+
({{ host }})
|
|
50
|
+
</text>
|
|
51
|
+
|
|
52
|
+
<!-- Meta line -->
|
|
53
|
+
<view class="flex flex-row flex-wrap" :style="{ marginTop: '4px', gap: '4px' }">
|
|
54
|
+
<text v-if="item.type !== 'job'" :style="{ color: '#595959', fontSize: '0.85em' }">
|
|
55
|
+
by
|
|
56
|
+
</text>
|
|
57
|
+
<text
|
|
58
|
+
v-if="item.type !== 'job'"
|
|
59
|
+
:style="{ color: '#595959', fontSize: '0.85em', textDecorationLine: 'underline' }"
|
|
60
|
+
@tap="goToUser"
|
|
61
|
+
>
|
|
62
|
+
{{ item.user }}
|
|
63
|
+
</text>
|
|
64
|
+
<text :style="{ color: '#595959', fontSize: '0.85em' }">
|
|
65
|
+
{{ item.time_ago }}
|
|
66
|
+
</text>
|
|
67
|
+
<text v-if="item.type !== 'job'" :style="{ color: '#595959', fontSize: '0.85em' }">
|
|
68
|
+
|
|
|
69
|
+
</text>
|
|
70
|
+
<text
|
|
71
|
+
v-if="item.type !== 'job'"
|
|
72
|
+
:style="{ color: '#595959', fontSize: '0.85em', textDecorationLine: 'underline' }"
|
|
73
|
+
@tap="goToItem"
|
|
74
|
+
>
|
|
75
|
+
{{ item.comments_count }} comments
|
|
76
|
+
</text>
|
|
77
|
+
</view>
|
|
78
|
+
</view>
|
|
79
|
+
</view>
|
|
80
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
show: boolean;
|
|
4
|
+
}>();
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<view
|
|
9
|
+
v-if="show"
|
|
10
|
+
:style="{
|
|
11
|
+
width: '28px',
|
|
12
|
+
height: '28px',
|
|
13
|
+
borderRadius: '14px',
|
|
14
|
+
borderWidth: '3px',
|
|
15
|
+
borderColor: '#eee',
|
|
16
|
+
borderTopColor: '#3eaf7c',
|
|
17
|
+
animationName: 'spin',
|
|
18
|
+
animationDuration: '0.8s',
|
|
19
|
+
animationTimingFunction: 'linear',
|
|
20
|
+
animationIterationCount: 'infinite',
|
|
21
|
+
}"
|
|
22
|
+
/>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<style>
|
|
26
|
+
@keyframes spin {
|
|
27
|
+
from { transform: rotate(0deg); }
|
|
28
|
+
to { transform: rotate(360deg); }
|
|
29
|
+
}
|
|
30
|
+
</style>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createApp } from 'vue-lynx';
|
|
2
|
+
import { createPinia } from 'pinia';
|
|
3
|
+
import { VueQueryPlugin } from '@tanstack/vue-query';
|
|
4
|
+
|
|
5
|
+
import router from './router';
|
|
6
|
+
import App from './App.vue';
|
|
7
|
+
|
|
8
|
+
const app = createApp(App);
|
|
9
|
+
app.use(createPinia());
|
|
10
|
+
app.use(VueQueryPlugin);
|
|
11
|
+
app.use(router);
|
|
12
|
+
|
|
13
|
+
router.push('/');
|
|
14
|
+
|
|
15
|
+
app.mount();
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue-lynx';
|
|
3
|
+
import { useRouter } from 'vue-router';
|
|
4
|
+
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/vue-query';
|
|
5
|
+
import { fetchFeed } from '../api';
|
|
6
|
+
import NewsItem from '../components/NewsItem.vue';
|
|
7
|
+
import Spinner from '../components/Spinner.vue';
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
feed: string;
|
|
11
|
+
page: number;
|
|
12
|
+
maxPage: number;
|
|
13
|
+
}>();
|
|
14
|
+
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const queryClient = useQueryClient();
|
|
17
|
+
|
|
18
|
+
const { data: items, isLoading, isFetching, isError } = useQuery({
|
|
19
|
+
queryKey: computed(() => ['feed', props.feed, props.page]),
|
|
20
|
+
queryFn: () => fetchFeed(props.feed, props.page),
|
|
21
|
+
staleTime: 5 * 60 * 1000,
|
|
22
|
+
placeholderData: keepPreviousData,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Track slide direction: "next" slides left, "prev" slides right
|
|
26
|
+
const slideDirection = ref<'slide-left' | 'slide-right'>('slide-left');
|
|
27
|
+
|
|
28
|
+
// Prefetch adjacent pages + track direction
|
|
29
|
+
watch(
|
|
30
|
+
() => [props.feed, props.page] as const,
|
|
31
|
+
([feed, page], old) => {
|
|
32
|
+
// Determine slide direction from page change
|
|
33
|
+
if (old) {
|
|
34
|
+
const [oldFeed, oldPage] = old;
|
|
35
|
+
slideDirection.value = (feed !== oldFeed || page >= oldPage) ? 'slide-left' : 'slide-right';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (page < props.maxPage) {
|
|
39
|
+
queryClient.prefetchQuery({
|
|
40
|
+
queryKey: ['feed', feed, page + 1],
|
|
41
|
+
queryFn: () => fetchFeed(feed, page + 1),
|
|
42
|
+
staleTime: 5 * 60 * 1000,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (page > 1) {
|
|
46
|
+
queryClient.prefetchQuery({
|
|
47
|
+
queryKey: ['feed', feed, page - 1],
|
|
48
|
+
queryFn: () => fetchFeed(feed, page - 1),
|
|
49
|
+
staleTime: 5 * 60 * 1000,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{ immediate: true },
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const hasPrev = computed(() => props.page > 1);
|
|
57
|
+
const hasNext = computed(() => props.page < props.maxPage);
|
|
58
|
+
|
|
59
|
+
function goPrev() {
|
|
60
|
+
if (hasPrev.value) {
|
|
61
|
+
router.push(`/${props.feed}/${props.page - 1}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function goNext() {
|
|
66
|
+
if (hasNext.value) {
|
|
67
|
+
router.push(`/${props.feed}/${props.page + 1}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
</script>
|
|
71
|
+
|
|
72
|
+
<template>
|
|
73
|
+
<view class="flex flex-col flex-1">
|
|
74
|
+
<!-- Pagination nav -->
|
|
75
|
+
<view
|
|
76
|
+
class="bg-hn-card flex flex-row items-center justify-center border-b border-hn-border"
|
|
77
|
+
:style="{ padding: '15px 30px', gap: '1em' }"
|
|
78
|
+
>
|
|
79
|
+
<text
|
|
80
|
+
:style="{
|
|
81
|
+
fontSize: '15px',
|
|
82
|
+
color: hasPrev ? '#3eaf7c' : '#595959',
|
|
83
|
+
opacity: hasPrev ? 1 : 0.5,
|
|
84
|
+
}"
|
|
85
|
+
@tap="goPrev"
|
|
86
|
+
>
|
|
87
|
+
< prev
|
|
88
|
+
</text>
|
|
89
|
+
<text :style="{ fontSize: '15px', color: '#2e495e' }">
|
|
90
|
+
{{ page }}/{{ maxPage }}
|
|
91
|
+
</text>
|
|
92
|
+
<text
|
|
93
|
+
:style="{
|
|
94
|
+
fontSize: '15px',
|
|
95
|
+
color: hasNext ? '#3eaf7c' : '#595959',
|
|
96
|
+
opacity: hasNext ? 1 : 0.5,
|
|
97
|
+
}"
|
|
98
|
+
@tap="goNext"
|
|
99
|
+
>
|
|
100
|
+
more >
|
|
101
|
+
</text>
|
|
102
|
+
</view>
|
|
103
|
+
|
|
104
|
+
<!-- Item list -->
|
|
105
|
+
<scroll-view class="flex-1" scroll-orientation="vertical">
|
|
106
|
+
<!-- Full loading (no cached data) -->
|
|
107
|
+
<view v-if="isLoading" class="items-center" :style="{ padding: '40px' }">
|
|
108
|
+
<Spinner :show="true" />
|
|
109
|
+
</view>
|
|
110
|
+
|
|
111
|
+
<view v-else-if="isError" class="items-center" :style="{ padding: '40px' }">
|
|
112
|
+
<text :style="{ fontSize: '15px', color: '#e53e3e' }">Failed to load stories.</text>
|
|
113
|
+
</view>
|
|
114
|
+
|
|
115
|
+
<template v-else>
|
|
116
|
+
<!-- Background refetch indicator -->
|
|
117
|
+
<view v-if="isFetching && !isLoading" class="items-center" :style="{ paddingTop: '12px' }">
|
|
118
|
+
<Spinner :show="true" />
|
|
119
|
+
</view>
|
|
120
|
+
|
|
121
|
+
<!-- Slide transition: direction flips based on prev/next -->
|
|
122
|
+
<Transition :name="slideDirection" mode="out-in" :duration="300">
|
|
123
|
+
<view class="bg-hn-card" :key="feed + '-' + page" :style="{ marginTop: '10px' }">
|
|
124
|
+
<NewsItem
|
|
125
|
+
v-for="item in items"
|
|
126
|
+
:key="item.id"
|
|
127
|
+
:item="item"
|
|
128
|
+
/>
|
|
129
|
+
</view>
|
|
130
|
+
</Transition>
|
|
131
|
+
</template>
|
|
132
|
+
</scroll-view>
|
|
133
|
+
</view>
|
|
134
|
+
</template>
|
|
135
|
+
|
|
136
|
+
<style>
|
|
137
|
+
/* Slide left: next page (enter from right, leave to left) */
|
|
138
|
+
.slide-left-enter-active,
|
|
139
|
+
.slide-left-leave-active,
|
|
140
|
+
.slide-right-enter-active,
|
|
141
|
+
.slide-right-leave-active {
|
|
142
|
+
transition: opacity 300ms cubic-bezier(0.55, 0, 0.1, 1), transform 300ms cubic-bezier(0.55, 0, 0.1, 1);
|
|
143
|
+
}
|
|
144
|
+
.slide-left-enter-from {
|
|
145
|
+
opacity: 0;
|
|
146
|
+
transform: translateX(30px);
|
|
147
|
+
}
|
|
148
|
+
.slide-left-leave-to {
|
|
149
|
+
opacity: 0;
|
|
150
|
+
transform: translateX(-30px);
|
|
151
|
+
}
|
|
152
|
+
/* Slide right: prev page (enter from left, leave to right) */
|
|
153
|
+
.slide-right-enter-from {
|
|
154
|
+
opacity: 0;
|
|
155
|
+
transform: translateX(-30px);
|
|
156
|
+
}
|
|
157
|
+
.slide-right-leave-to {
|
|
158
|
+
opacity: 0;
|
|
159
|
+
transform: translateX(30px);
|
|
160
|
+
}
|
|
161
|
+
</style>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue-lynx';
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
4
|
+
import { useQuery } from '@tanstack/vue-query';
|
|
5
|
+
import { fetchItem } from '../api';
|
|
6
|
+
import { toHost, isAbsoluteUrl } from '../utils';
|
|
7
|
+
import Comment from '../components/Comment.vue';
|
|
8
|
+
import Spinner from '../components/Spinner.vue';
|
|
9
|
+
|
|
10
|
+
const route = useRoute();
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
|
|
13
|
+
const itemId = computed(() => Number(route.params.id));
|
|
14
|
+
|
|
15
|
+
const { data: item, isLoading, isError } = useQuery({
|
|
16
|
+
queryKey: computed(() => ['item', itemId.value]),
|
|
17
|
+
queryFn: () => fetchItem(itemId.value),
|
|
18
|
+
staleTime: 60 * 1000,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const host = computed(() => item.value ? toHost(item.value.url) : '');
|
|
22
|
+
|
|
23
|
+
function goToUser() {
|
|
24
|
+
if (item.value) {
|
|
25
|
+
router.push(`/user/${item.value.user}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function goBack() {
|
|
30
|
+
router.back();
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<scroll-view class="flex-1" scroll-orientation="vertical">
|
|
36
|
+
<!-- Loading -->
|
|
37
|
+
<view v-if="isLoading" class="items-center" :style="{ padding: '40px' }">
|
|
38
|
+
<Spinner :show="true" />
|
|
39
|
+
</view>
|
|
40
|
+
|
|
41
|
+
<!-- Error -->
|
|
42
|
+
<view v-else-if="isError" class="items-center" :style="{ padding: '40px' }">
|
|
43
|
+
<text :style="{ fontSize: '15px', color: '#e53e3e' }">Failed to load item.</text>
|
|
44
|
+
</view>
|
|
45
|
+
|
|
46
|
+
<template v-else-if="item">
|
|
47
|
+
<!-- Item header card -->
|
|
48
|
+
<view class="bg-hn-card flex flex-col" :style="{ padding: '1.8em 2em 1em' }">
|
|
49
|
+
<!-- Back link -->
|
|
50
|
+
<text
|
|
51
|
+
:style="{ color: '#3eaf7c', fontSize: '13px', marginBottom: '12px' }"
|
|
52
|
+
@tap="goBack"
|
|
53
|
+
>
|
|
54
|
+
< Back
|
|
55
|
+
</text>
|
|
56
|
+
|
|
57
|
+
<!-- Title: 1.5em like reference h1 -->
|
|
58
|
+
<text :style="{ color: '#2e495e', fontSize: '1.5em', fontWeight: 'bold', lineHeight: '1.3em' }">
|
|
59
|
+
{{ item.title }}
|
|
60
|
+
</text>
|
|
61
|
+
|
|
62
|
+
<!-- Host -->
|
|
63
|
+
<text
|
|
64
|
+
v-if="isAbsoluteUrl(item.url)"
|
|
65
|
+
:style="{ color: '#595959', fontSize: '0.85em', marginTop: '4px' }"
|
|
66
|
+
>
|
|
67
|
+
{{ host }}
|
|
68
|
+
</text>
|
|
69
|
+
|
|
70
|
+
<!-- Meta -->
|
|
71
|
+
<view class="flex flex-row flex-wrap" :style="{ marginTop: '8px', gap: '4px' }">
|
|
72
|
+
<text :style="{ color: '#595959', fontSize: '0.85em' }">
|
|
73
|
+
{{ item.points }} points | by
|
|
74
|
+
</text>
|
|
75
|
+
<text
|
|
76
|
+
:style="{ color: '#595959', fontSize: '0.85em', textDecorationLine: 'underline' }"
|
|
77
|
+
@tap="goToUser"
|
|
78
|
+
>
|
|
79
|
+
{{ item.user }}
|
|
80
|
+
</text>
|
|
81
|
+
<text :style="{ color: '#595959', fontSize: '0.85em' }">
|
|
82
|
+
{{ item.time_ago }}
|
|
83
|
+
</text>
|
|
84
|
+
</view>
|
|
85
|
+
</view>
|
|
86
|
+
|
|
87
|
+
<!-- Comments section -->
|
|
88
|
+
<view class="bg-hn-card flex flex-col" :style="{ marginTop: '10px', padding: '0 2em 0.5em' }">
|
|
89
|
+
<!-- Comments header -->
|
|
90
|
+
<view class="flex flex-row items-center" :style="{ padding: '1em 0', gap: '8px' }">
|
|
91
|
+
<text :style="{ fontSize: '1.1em', color: '#2e495e' }">
|
|
92
|
+
{{
|
|
93
|
+
item.comments && item.comments.length
|
|
94
|
+
? item.comments.length + ' comments'
|
|
95
|
+
: 'No comments yet'
|
|
96
|
+
}}
|
|
97
|
+
</text>
|
|
98
|
+
</view>
|
|
99
|
+
|
|
100
|
+
<Comment
|
|
101
|
+
v-for="comment in item.comments"
|
|
102
|
+
:key="comment.id"
|
|
103
|
+
:comment="comment"
|
|
104
|
+
:depth="0"
|
|
105
|
+
/>
|
|
106
|
+
</view>
|
|
107
|
+
</template>
|
|
108
|
+
</scroll-view>
|
|
109
|
+
</template>
|