@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
|
@@ -0,0 +1,89 @@
|
|
|
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 { fetchUser } from '../api';
|
|
6
|
+
import { stripHtml } from '../utils';
|
|
7
|
+
import Spinner from '../components/Spinner.vue';
|
|
8
|
+
|
|
9
|
+
const route = useRoute();
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
|
|
12
|
+
const userId = computed(() => route.params.id as string);
|
|
13
|
+
|
|
14
|
+
const { data: user, isLoading, isError } = useQuery({
|
|
15
|
+
queryKey: computed(() => ['user', userId.value]),
|
|
16
|
+
queryFn: () => fetchUser(userId.value),
|
|
17
|
+
staleTime: 60 * 1000,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function goBack() {
|
|
21
|
+
router.back();
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<scroll-view class="flex-1" scroll-orientation="vertical">
|
|
27
|
+
<!-- Loading -->
|
|
28
|
+
<view v-if="isLoading" class="items-center" :style="{ padding: '40px' }">
|
|
29
|
+
<Spinner :show="true" />
|
|
30
|
+
</view>
|
|
31
|
+
|
|
32
|
+
<!-- Error -->
|
|
33
|
+
<view v-else-if="isError" class="items-center flex flex-col" :style="{ padding: '2em 3em', gap: '8px' }">
|
|
34
|
+
<text :style="{ fontSize: '1.5em', fontWeight: 'bold', color: '#2e495e' }">
|
|
35
|
+
User not found.
|
|
36
|
+
</text>
|
|
37
|
+
<text :style="{ color: '#3eaf7c', fontSize: '15px' }" @tap="goBack">
|
|
38
|
+
< Back
|
|
39
|
+
</text>
|
|
40
|
+
</view>
|
|
41
|
+
|
|
42
|
+
<template v-else-if="user">
|
|
43
|
+
<view class="bg-hn-card flex flex-col" :style="{ padding: '2em 3em' }">
|
|
44
|
+
<!-- Back link -->
|
|
45
|
+
<text
|
|
46
|
+
:style="{ color: '#3eaf7c', fontSize: '13px', marginBottom: '12px' }"
|
|
47
|
+
@tap="goBack"
|
|
48
|
+
>
|
|
49
|
+
< Back
|
|
50
|
+
</text>
|
|
51
|
+
|
|
52
|
+
<!-- User ID: h1 equivalent -->
|
|
53
|
+
<text :style="{ fontSize: '1.5em', fontWeight: 'bold', color: '#2e495e' }">
|
|
54
|
+
User : {{ user.id }}
|
|
55
|
+
</text>
|
|
56
|
+
|
|
57
|
+
<!-- Meta -->
|
|
58
|
+
<view class="flex flex-col" :style="{ marginTop: '12px', gap: '4px' }">
|
|
59
|
+
<view class="flex flex-row">
|
|
60
|
+
<text :style="{ color: '#595959', fontSize: '15px', width: '4em' }">Created:</text>
|
|
61
|
+
<text :style="{ color: '#2e495e', fontSize: '15px' }">{{ user.created }}</text>
|
|
62
|
+
</view>
|
|
63
|
+
<view class="flex flex-row">
|
|
64
|
+
<text :style="{ color: '#595959', fontSize: '15px', width: '4em' }">Karma:</text>
|
|
65
|
+
<text :style="{ color: '#2e495e', fontSize: '15px' }">{{ user.karma }}</text>
|
|
66
|
+
</view>
|
|
67
|
+
</view>
|
|
68
|
+
|
|
69
|
+
<!-- About -->
|
|
70
|
+
<view v-if="user.about" class="border-t border-hn-border" :style="{ marginTop: '1em', paddingTop: '1em' }">
|
|
71
|
+
<text :style="{ color: '#2e495e', fontSize: '15px', lineHeight: '1.5em' }">
|
|
72
|
+
{{ stripHtml(user.about) }}
|
|
73
|
+
</text>
|
|
74
|
+
</view>
|
|
75
|
+
|
|
76
|
+
<!-- Links -->
|
|
77
|
+
<view class="flex flex-row" :style="{ marginTop: '1em', gap: '6px' }">
|
|
78
|
+
<text :style="{ color: '#2e495e', fontSize: '15px', textDecorationLine: 'underline' }">
|
|
79
|
+
submissions
|
|
80
|
+
</text>
|
|
81
|
+
<text :style="{ color: '#2e495e', fontSize: '15px' }">|</text>
|
|
82
|
+
<text :style="{ color: '#2e495e', fontSize: '15px', textDecorationLine: 'underline' }">
|
|
83
|
+
comments
|
|
84
|
+
</text>
|
|
85
|
+
</view>
|
|
86
|
+
</view>
|
|
87
|
+
</template>
|
|
88
|
+
</scroll-view>
|
|
89
|
+
</template>
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
2
|
+
import { validFeeds } from './api';
|
|
3
|
+
import FeedList from './pages/FeedList.vue';
|
|
4
|
+
import ItemPage from './pages/ItemPage.vue';
|
|
5
|
+
import UserPage from './pages/UserPage.vue';
|
|
6
|
+
|
|
7
|
+
const router = createRouter({
|
|
8
|
+
history: createMemoryHistory(),
|
|
9
|
+
routes: [
|
|
10
|
+
{
|
|
11
|
+
path: '/',
|
|
12
|
+
redirect: '/news',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
path: '/:feed/:page?',
|
|
16
|
+
name: 'feed-page',
|
|
17
|
+
component: FeedList,
|
|
18
|
+
props: (route) => ({
|
|
19
|
+
feed: route.params.feed as string,
|
|
20
|
+
page: Number(route.params.page) || 1,
|
|
21
|
+
maxPage: validFeeds[route.params.feed as string]?.pages ?? 1,
|
|
22
|
+
}),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
path: '/item/:id',
|
|
26
|
+
name: 'item-id',
|
|
27
|
+
component: ItemPage,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
path: '/user/:id',
|
|
31
|
+
name: 'user-id',
|
|
32
|
+
component: UserPage,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export default router;
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function toHost(url: string): string {
|
|
2
|
+
if (!url) return '';
|
|
3
|
+
const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
4
|
+
const parts = host.split('.').slice(-3);
|
|
5
|
+
if (parts[0] === 'www') parts.shift();
|
|
6
|
+
return parts.join('.');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isAbsoluteUrl(url: string): boolean {
|
|
10
|
+
return /^https?:\/\//.test(url);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function pluralize(n: number, singular: string, plural?: string): string {
|
|
14
|
+
return n === 1 ? `${n} ${singular}` : `${n} ${plural ?? singular + 's'}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const entityMap: Record<string, string> = {
|
|
18
|
+
'&': '&', '<': '<', '>': '>', '"': '"',
|
|
19
|
+
''': "'", ''': "'", '/': '/', ' ': ' ',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function stripHtml(html: string): string {
|
|
23
|
+
return html
|
|
24
|
+
.replace(/<[^>]*>/g, '')
|
|
25
|
+
.replace(/&[#\w]+;/g, (m) => entityMap[m] ?? m);
|
|
26
|
+
}
|