@vue-lynx-example/hackernews-css 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.
@@ -0,0 +1,243 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue-lynx';
3
+ import { useRouter } from 'vue-router';
4
+ import {
5
+ keepPreviousData,
6
+ useQuery,
7
+ useQueryClient,
8
+ } from '@tanstack/vue-query';
9
+ import { fetchFeed } from '../api';
10
+ import NewsItem from '../components/NewsItem.vue';
11
+ import Spinner from '../components/Spinner.vue';
12
+
13
+ const props = defineProps<{
14
+ feed: string;
15
+ page: number;
16
+ maxPage: number;
17
+ }>();
18
+
19
+ const router = useRouter();
20
+ const queryClient = useQueryClient();
21
+ const slideDirection = ref<'slide-left' | 'slide-right'>('slide-left');
22
+
23
+ const { data: items, isLoading, isFetching, isError } = useQuery({
24
+ queryKey: computed(() => ['feed', props.feed, props.page]),
25
+ queryFn: () => fetchFeed(props.feed, props.page),
26
+ staleTime: 5 * 60 * 1000,
27
+ placeholderData: keepPreviousData,
28
+ });
29
+
30
+ watch(
31
+ () => [props.feed, props.page] as const,
32
+ ([feed, page], oldValue) => {
33
+ if (oldValue) {
34
+ const [oldFeed, oldPage] = oldValue;
35
+ slideDirection.value =
36
+ feed !== oldFeed || page >= oldPage ? 'slide-left' : 'slide-right';
37
+ }
38
+
39
+ if (page < props.maxPage) {
40
+ queryClient.prefetchQuery({
41
+ queryKey: ['feed', feed, page + 1],
42
+ queryFn: () => fetchFeed(feed, page + 1),
43
+ staleTime: 5 * 60 * 1000,
44
+ });
45
+ }
46
+
47
+ if (page > 1) {
48
+ queryClient.prefetchQuery({
49
+ queryKey: ['feed', feed, page - 1],
50
+ queryFn: () => fetchFeed(feed, page - 1),
51
+ staleTime: 5 * 60 * 1000,
52
+ });
53
+ }
54
+ },
55
+ { immediate: true },
56
+ );
57
+
58
+ const hasPrev = computed(() => props.page > 1);
59
+ const hasNext = computed(() => props.page < props.maxPage);
60
+
61
+ function goPrev() {
62
+ if (hasPrev.value) {
63
+ router.push(`/${props.feed}/${props.page - 1}`);
64
+ }
65
+ }
66
+
67
+ function goNext() {
68
+ if (hasNext.value) {
69
+ router.push(`/${props.feed}/${props.page + 1}`);
70
+ }
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <scroll-view class="view feed-view" scroll-orientation="vertical">
76
+ <view class="news-list-nav">
77
+ <text class="nav-button" :class="{ disabled: !hasPrev }" @tap="goPrev">
78
+ &lt; prev
79
+ </text>
80
+ <text class="nav-page">{{ page }}/{{ maxPage }}</text>
81
+ <text class="nav-button" :class="{ disabled: !hasNext }" @tap="goNext">
82
+ more &gt;
83
+ </text>
84
+ </view>
85
+
86
+ <view class="feed-content">
87
+ <view v-if="isLoading" class="status-card">
88
+ <Spinner :show="true" />
89
+ </view>
90
+
91
+ <view v-else-if="isError" class="status-card">
92
+ <text class="status-error">Failed to load stories.</text>
93
+ </view>
94
+
95
+ <template v-else>
96
+ <view v-if="isFetching" class="status-inline">
97
+ <Spinner :show="true" />
98
+ </view>
99
+
100
+ <Transition :name="slideDirection" mode="out-in" :duration="300">
101
+ <view :key="`${feed}-${page}`" class="news-list">
102
+ <TransitionGroup name="item" tag="view" :duration="500">
103
+ <NewsItem v-for="item in items" :key="item.id" :item="item" />
104
+ </TransitionGroup>
105
+ </view>
106
+ </Transition>
107
+ </template>
108
+ </view>
109
+ </scroll-view>
110
+ </template>
111
+
112
+ <style lang="scss">
113
+ .feed-view {
114
+ height: 100%;
115
+ }
116
+
117
+ .feed-content {
118
+ padding-top: 56px;
119
+ padding-bottom: 30px;
120
+ }
121
+
122
+ .news-list-nav {
123
+ position: fixed;
124
+ top: 55px;
125
+ z-index: 998;
126
+ left: 0;
127
+ right: 0;
128
+ width: 100%;
129
+ padding: 15px 30px;
130
+ text-align: center;
131
+ background-color: #fff;
132
+ border-radius: 2px;
133
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
134
+ display: flex;
135
+ flex-direction: row;
136
+ align-items: center;
137
+ justify-content: center;
138
+ }
139
+
140
+ .nav-button {
141
+ margin: 0 1em;
142
+ color: #2e495e;
143
+ }
144
+
145
+ .nav-button.disabled {
146
+ opacity: 0.8;
147
+ }
148
+
149
+ .nav-page {
150
+ color: #2e495e;
151
+ }
152
+
153
+ .news-list {
154
+ background-color: #fff;
155
+ border-radius: 2px;
156
+ max-width: 800px;
157
+ margin: 30px auto;
158
+ width: 100%;
159
+ transition: all 0.3s cubic-bezier(0.55, 0, 0.1, 1);
160
+ }
161
+
162
+ .status-card {
163
+ background-color: #fff;
164
+ border-radius: 2px;
165
+ max-width: 800px;
166
+ margin: 30px auto;
167
+ padding: 40px;
168
+ display: flex;
169
+ align-items: center;
170
+ justify-content: center;
171
+ }
172
+
173
+ .status-inline {
174
+ display: flex;
175
+ justify-content: center;
176
+ padding-top: 12px;
177
+ }
178
+
179
+ .status-error {
180
+ color: #c53030;
181
+ }
182
+
183
+ .slide-left-enter-active,
184
+ .slide-left-leave-active,
185
+ .slide-right-enter-active,
186
+ .slide-right-leave-active {
187
+ transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
188
+ }
189
+
190
+ .slide-left-enter-from,
191
+ .slide-right-leave-to {
192
+ opacity: 0;
193
+ transform: translate(30px, 0);
194
+ }
195
+
196
+ .slide-left-leave-to,
197
+ .slide-right-enter-from {
198
+ opacity: 0;
199
+ transform: translate(-30px, 0);
200
+ }
201
+
202
+ .item-move,
203
+ .item-enter-active,
204
+ .item-leave-active {
205
+ transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
206
+ }
207
+
208
+ .item-enter-from {
209
+ opacity: 0;
210
+ transform: translate(30px, 0);
211
+ }
212
+
213
+ .item-leave-to {
214
+ position: absolute;
215
+ opacity: 0;
216
+ transform: translate(30px, 0);
217
+ }
218
+
219
+ @media (max-width: 860px) {
220
+ .news-list-nav {
221
+ max-width: calc(100% - 60px);
222
+ }
223
+ }
224
+
225
+ @media (max-width: 600px) {
226
+ .news-list {
227
+ margin: 10px auto;
228
+ }
229
+
230
+ .news-list-nav {
231
+ max-width: calc(100% - 30px);
232
+ padding: 15px;
233
+ }
234
+
235
+ .status-card {
236
+ margin: 10px auto;
237
+ }
238
+
239
+ .feed-content {
240
+ padding-top: 52px;
241
+ }
242
+ }
243
+ </style>
@@ -0,0 +1,183 @@
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 Comment from '../components/Comment.vue';
7
+ import Spinner from '../components/Spinner.vue';
8
+ import { isAbsoluteUrl, openExternalUrl, toHost } from '../utils';
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 itemUrl = computed(() => (item.value ? toHost(item.value.url) : ''));
22
+
23
+ function goBack() {
24
+ router.back();
25
+ }
26
+
27
+ function goToUser() {
28
+ if (item.value) {
29
+ router.push(`/user/${item.value.user}`);
30
+ }
31
+ }
32
+
33
+ function openItemLink() {
34
+ if (item.value && isAbsoluteUrl(item.value.url)) {
35
+ openExternalUrl(item.value.url);
36
+ }
37
+ }
38
+ </script>
39
+
40
+ <template>
41
+ <scroll-view class="view item-view" scroll-orientation="vertical">
42
+ <view v-if="isLoading" class="status-card">
43
+ <Spinner :show="true" />
44
+ </view>
45
+
46
+ <view v-else-if="isError" class="status-card">
47
+ <text class="status-error">Failed to load item.</text>
48
+ </view>
49
+
50
+ <template v-else-if="item">
51
+ <view class="item-view-header">
52
+ <text class="back-link" @tap="goBack">&lt; Back</text>
53
+
54
+ <view class="item-title-row">
55
+ <text class="item-title" @tap="openItemLink">{{ item.title }}</text>
56
+ <text v-if="isAbsoluteUrl(item.url)" class="host">{{ itemUrl }}</text>
57
+ </view>
58
+
59
+ <view class="meta">
60
+ <text>{{ item.points }} points | by </text>
61
+ <text class="meta-link" @tap="goToUser">{{ item.user }}</text>
62
+ <text> {{ item.time_ago }}</text>
63
+ </view>
64
+ </view>
65
+
66
+ <view class="item-view-comments">
67
+ <view class="item-view-comments-header">
68
+ <text>
69
+ {{
70
+ item.comments && item.comments.length
71
+ ? item.comments.length + ' comments'
72
+ : 'No comments yet'
73
+ }}
74
+ </text>
75
+ </view>
76
+
77
+ <view class="comment-children">
78
+ <Comment
79
+ v-for="comment in item.comments"
80
+ :key="comment.id"
81
+ :comment="comment"
82
+ />
83
+ </view>
84
+ </view>
85
+ </template>
86
+ </scroll-view>
87
+ </template>
88
+
89
+ <style lang="scss">
90
+ .item-view {
91
+ height: 100%;
92
+ padding-bottom: 30px;
93
+ }
94
+
95
+ .item-view-header {
96
+ background-color: #fff;
97
+ padding: 1.8em 2em 1em;
98
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
99
+ max-width: 800px;
100
+ margin: 0 auto;
101
+ }
102
+
103
+ .back-link {
104
+ color: #3eaf7c;
105
+ font-size: 0.85em;
106
+ text-decoration: underline;
107
+ margin-bottom: 12px;
108
+ }
109
+
110
+ .item-title-row {
111
+ display: flex;
112
+ flex-direction: row;
113
+ flex-wrap: wrap;
114
+ gap: 0.5em;
115
+ }
116
+
117
+ .item-title {
118
+ font-size: 1.5em;
119
+ margin-right: 0.5em;
120
+ }
121
+
122
+ .item-view-header {
123
+ .host,
124
+ .meta,
125
+ .meta-link {
126
+ color: #595959;
127
+ }
128
+
129
+ .meta {
130
+ display: flex;
131
+ flex-direction: row;
132
+ flex-wrap: wrap;
133
+ margin-top: 8px;
134
+ }
135
+
136
+ .meta-link {
137
+ text-decoration: underline;
138
+ }
139
+ }
140
+
141
+ .item-view-comments {
142
+ background-color: #fff;
143
+ margin-top: 10px;
144
+ padding: 0 2em 0.5em;
145
+ max-width: 800px;
146
+ margin-left: auto;
147
+ margin-right: auto;
148
+ }
149
+
150
+ .item-view-comments-header {
151
+ margin: 0;
152
+ font-size: 1.1em;
153
+ padding: 1em 0;
154
+ position: relative;
155
+ }
156
+
157
+ .comment-children {
158
+ padding: 0;
159
+ margin: 0;
160
+ }
161
+
162
+ .status-card {
163
+ background-color: #fff;
164
+ margin-top: 30px;
165
+ padding: 40px;
166
+ max-width: 800px;
167
+ margin-left: auto;
168
+ margin-right: auto;
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ }
173
+
174
+ .status-error {
175
+ color: #c53030;
176
+ }
177
+
178
+ @media (max-width: 600px) {
179
+ .item-title {
180
+ font-size: 1.25em;
181
+ }
182
+ }
183
+ </style>
@@ -0,0 +1,146 @@
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 Spinner from '../components/Spinner.vue';
7
+ import { openExternalUrl, stripHtml } from '../utils';
8
+
9
+ const route = useRoute();
10
+ const router = useRouter();
11
+
12
+ const userId = computed(() => String(route.params.id ?? ''));
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
+
24
+ function openSubmissions() {
25
+ if (user.value) {
26
+ openExternalUrl(`https://news.ycombinator.com/submitted?id=${user.value.id}`);
27
+ }
28
+ }
29
+
30
+ function openComments() {
31
+ if (user.value) {
32
+ openExternalUrl(`https://news.ycombinator.com/threads?id=${user.value.id}`);
33
+ }
34
+ }
35
+ </script>
36
+
37
+ <template>
38
+ <scroll-view class="view page-scroll" scroll-orientation="vertical">
39
+ <view v-if="isLoading" class="status-card">
40
+ <Spinner :show="true" />
41
+ </view>
42
+
43
+ <view v-else-if="isError" class="status-card">
44
+ <text class="status-title">User not found.</text>
45
+ <text class="back-link" @tap="goBack">&lt; Back</text>
46
+ </view>
47
+
48
+ <view v-else-if="user" class="user-view">
49
+ <text class="back-link" @tap="goBack">&lt; Back</text>
50
+ <text class="user-title">User : {{ user.id }}</text>
51
+
52
+ <view class="meta">
53
+ <view class="meta-row">
54
+ <text class="label">Created:</text>
55
+ <text>{{ user.created }}</text>
56
+ </view>
57
+ <view class="meta-row">
58
+ <text class="label">Karma:</text>
59
+ <text>{{ user.karma }}</text>
60
+ </view>
61
+ </view>
62
+
63
+ <text v-if="user.about" class="about">{{ stripHtml(user.about) }}</text>
64
+
65
+ <view class="links">
66
+ <text class="links-link" @tap="openSubmissions">submissions</text>
67
+ <text>|</text>
68
+ <text class="links-link" @tap="openComments">comments</text>
69
+ </view>
70
+ </view>
71
+ </scroll-view>
72
+ </template>
73
+
74
+ <style lang="scss">
75
+ .page-scroll {
76
+ height: 100%;
77
+ padding-bottom: 30px;
78
+ }
79
+
80
+ .user-view {
81
+ background-color: #fff;
82
+ box-sizing: border-box;
83
+ padding: 2em 3em;
84
+ margin: 30px auto 0;
85
+ max-width: 800px;
86
+ }
87
+
88
+ .user-title {
89
+ margin: 0;
90
+ font-size: 1.5em;
91
+ }
92
+
93
+ .meta {
94
+ padding: 0;
95
+ margin-top: 1em;
96
+ }
97
+
98
+ .meta-row {
99
+ display: flex;
100
+ flex-direction: row;
101
+ margin-bottom: 4px;
102
+ }
103
+
104
+ .label {
105
+ display: inline-block;
106
+ min-width: 4em;
107
+ color: #595959;
108
+ }
109
+
110
+ .about {
111
+ margin: 1em 0;
112
+ white-space: pre-wrap;
113
+ line-height: 1.5em;
114
+ }
115
+
116
+ .links {
117
+ display: flex;
118
+ flex-direction: row;
119
+ gap: 6px;
120
+ }
121
+
122
+ .links-link {
123
+ text-decoration: underline;
124
+ }
125
+
126
+ .back-link {
127
+ color: #3eaf7c;
128
+ font-size: 0.85em;
129
+ text-decoration: underline;
130
+ margin-bottom: 12px;
131
+ }
132
+
133
+ .status-card {
134
+ background-color: #fff;
135
+ margin: 30px auto 0;
136
+ max-width: 800px;
137
+ padding: 2em 3em;
138
+ display: flex;
139
+ flex-direction: column;
140
+ gap: 8px;
141
+ }
142
+
143
+ .status-title {
144
+ font-size: 1.5em;
145
+ }
146
+ </style>
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;
@@ -0,0 +1,5 @@
1
+ declare module '*.vue' {
2
+ import type { DefineComponent } from 'vue-lynx';
3
+ const component: DefineComponent;
4
+ export default component;
5
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,55 @@
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
+ '&amp;': '&',
19
+ '&lt;': '<',
20
+ '&gt;': '>',
21
+ '&quot;': '"',
22
+ '&#39;': "'",
23
+ '&#x27;': "'",
24
+ '&#x2F;': '/',
25
+ '&nbsp;': ' ',
26
+ };
27
+
28
+ export function stripHtml(html: string): string {
29
+ return html
30
+ .replace(/<br\s*\/?>/gi, '\n')
31
+ .replace(/<\/p>\s*<p>/gi, '\n\n')
32
+ .replace(/<\/li>/gi, '\n')
33
+ .replace(/<li>/gi, '• ')
34
+ .replace(/<[^>]*>/g, '')
35
+ .replace(/&[#\w]+;/g, (m) => entityMap[m] ?? m)
36
+ .trim();
37
+ }
38
+
39
+ export function openExternalUrl(url: string): void {
40
+ if (!url) return;
41
+
42
+ const scope = globalThis as typeof globalThis & {
43
+ open?: (url: string, target?: string) => unknown;
44
+ location?: { href?: string };
45
+ };
46
+
47
+ if (typeof scope.open === 'function') {
48
+ scope.open(url, '_blank');
49
+ return;
50
+ }
51
+
52
+ if (scope.location) {
53
+ scope.location.href = url;
54
+ }
55
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "noEmit": true,
6
+ "noUnusedLocals": false,
7
+ "noUnusedParameters": false
8
+ },
9
+ "include": ["src", "lynx.config.ts"]
10
+ }