feeds-fun 1.18.1 → 1.19.0

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,54 @@
1
+ import {useRoute, useRouter} from "vue-router";
2
+ import type {RouteLocationNormalizedLoaded, Router} from "vue-router";
3
+ import * as settings from "@/logic/settings";
4
+
5
+ export function processUTM(route: RouteLocationNormalizedLoaded, router: Router, utmStorage: any) {
6
+ const utmParams = ["utm_source", "utm_medium", "utm_campaign"];
7
+
8
+ // extract UTM parameters from the URL
9
+ const utmData = utmParams.reduce(
10
+ (acc, param) => {
11
+ const value = route.query[param];
12
+
13
+ if (!value) {
14
+ return acc;
15
+ }
16
+
17
+ if (Array.isArray(value)) {
18
+ if (value[0]) {
19
+ acc[param] = value[0];
20
+ }
21
+
22
+ return acc;
23
+ }
24
+
25
+ if (value) {
26
+ acc[param] = value;
27
+ return acc;
28
+ }
29
+
30
+ return acc;
31
+ },
32
+ {} as Record<string, string>
33
+ );
34
+
35
+ // remove UTM parameters from the URL if they exist
36
+ if (Object.keys(utmData).length > 0) {
37
+ const newQuery = {...route.query};
38
+
39
+ utmParams.forEach((param) => {
40
+ if (newQuery[param]) {
41
+ delete newQuery[param];
42
+ }
43
+ });
44
+
45
+ router.replace({query: newQuery});
46
+ }
47
+
48
+ // store UTM in local storage
49
+ if (Object.keys(utmData).length == 0) {
50
+ return;
51
+ }
52
+
53
+ utmStorage.value = utmData;
54
+ }
@@ -22,6 +22,13 @@ export const plausibleEnabled = import.meta.env.VITE_FFUN_PLAUSIBLE_ENABLED == "
22
22
  export const plausibleDomain = import.meta.env.VITE_FFUN_PLAUSIBLE_DOMAIN || "localhost";
23
23
  export const plausibleScript = import.meta.env.VITE_FFUN_PLAUSIBLE_SCRIPT || "";
24
24
 
25
+ export const trackEvents = import.meta.env.VITE_FFUN_TRACK_EVENTS == "true" || false;
26
+
27
+ export const utmLifetime = import.meta.env.VITE_FFUN_UTM_LIFETIME || 7; // days
28
+
29
+ export const crmTerms = import.meta.env.VITE_FFUN_CRM_TERMS || null;
30
+ export const crmPrivacy = import.meta.env.VITE_FFUN_CRM_PRIVACY || null;
31
+
25
32
  console.log("settings.appName", appName);
26
33
  console.log("settings.appDomain", appDomain);
27
34
  console.log("settings.appPort", appPort);
@@ -40,3 +47,12 @@ console.log("settings.redditSubreddit", redditSubreddit);
40
47
  console.log("settings.plausibleEnabled", plausibleEnabled);
41
48
  console.log("settings.plausibleDomain", plausibleDomain);
42
49
  console.log("settings.plausibleScript", plausibleScript);
50
+
51
+ console.log("settings.trackEvents", trackEvents);
52
+
53
+ console.log("settings.utmLifetime", utmLifetime);
54
+
55
+ console.log("settings.crmTerms", crmTerms ? "set" : "not set");
56
+ console.log("settings.crmPrivacy", crmPrivacy ? "set" : "not set");
57
+
58
+ export const isSingleUserMode = authMode === AuthMode.SingleUser;
package/src/main.ts CHANGED
@@ -6,6 +6,8 @@ import router from "./router";
6
6
 
7
7
  import "./style.css";
8
8
 
9
+ import * as CookieConsent from "./plugins/CookieConsent";
10
+
9
11
  import FeedsList from "./components/FeedsList.vue";
10
12
  import EntriesList from "./components/EntriesList.vue";
11
13
  import RulesList from "./components/RulesList.vue";
@@ -33,7 +35,9 @@ import TagsFilter from "./components/tags/TagsFilter.vue";
33
35
  import RuleTag from "./components/tags/RuleTag.vue";
34
36
  import FakeTag from "./components/tags/FakeTag.vue";
35
37
 
38
+ import PageHeaderHomeButton from "./components/page_header/HomeButton.vue";
36
39
  import PageHeaderExternalLinks from "./components/page_header/ExternalLinks.vue";
40
+ import PageFooter from "./components/page_footer/Footer.vue";
37
41
 
38
42
  import NotificationsApiKey from "./components/notifications/ApiKey.vue";
39
43
  import NotificationsCreateRuleHelp from "./components/notifications/CreateRuleHelp.vue";
@@ -56,6 +60,8 @@ import ExternalUrl from "./values/ExternalUrl.vue";
56
60
  import ValueFeedId from "./values/FeedId.vue";
57
61
  import ValueDateTime from "./values/DateTime.vue";
58
62
  import ValueScore from "./values/Score.vue";
63
+ import Icon from "./values/Icon.vue";
64
+ import SocialLink from "./values/SocialLink.vue";
59
65
 
60
66
  import BodyListReverseTimeColumn from "./components/body_list/ReverseTimeColumn.vue";
61
67
  import BodyListFaviconColumn from "./components/body_list/FaviconColumn.vue";
@@ -105,7 +111,9 @@ app.component("TagsFilter", TagsFilter);
105
111
  app.component("RuleTag", RuleTag);
106
112
  app.component("FakeTag", FakeTag);
107
113
 
114
+ app.component("PageHeaderHomeButton", PageHeaderHomeButton);
108
115
  app.component("PageHeaderExternalLinks", PageHeaderExternalLinks);
116
+ app.component("PageFooter", PageFooter);
109
117
 
110
118
  app.component("NotificationsApiKey", NotificationsApiKey);
111
119
  app.component("NotificationsCreateRuleHelp", NotificationsCreateRuleHelp);
@@ -128,6 +136,8 @@ app.component("ExternalUrl", ExternalUrl);
128
136
  app.component("ValueFeedId", ValueFeedId);
129
137
  app.component("ValueDateTime", ValueDateTime);
130
138
  app.component("ValueScore", ValueScore);
139
+ app.component("Icon", Icon);
140
+ app.component("SocialLink", SocialLink);
131
141
 
132
142
  app.component("BodyListReverseTimeColumn", BodyListReverseTimeColumn);
133
143
  app.component("BodyListFaviconColumn", BodyListFaviconColumn);
@@ -149,6 +159,10 @@ app.component("vue-countdown", VueCountdown);
149
159
  app.use(createPinia());
150
160
  app.use(router);
151
161
 
162
+ if (!settings.isSingleUserMode) {
163
+ app.use(CookieConsent.plugin, CookieConsent.defaultConfig);
164
+ }
165
+
152
166
  app.mount("#app");
153
167
 
154
168
  import * as api from "@/logic/api";
@@ -196,16 +210,3 @@ async function onSessionLost() {
196
210
  }
197
211
 
198
212
  api.init({onSessionLost: onSessionLost});
199
-
200
- /////////////////////
201
- // plausible
202
- /////////////////////
203
-
204
- if (settings.plausibleEnabled) {
205
- const script = document.createElement("script");
206
- script.src = settings.plausibleScript;
207
- script.async = true;
208
- script.defer = true;
209
- script.setAttribute("data-domain", settings.plausibleDomain);
210
- document.body.appendChild(script);
211
- }
@@ -0,0 +1,170 @@
1
+ import "vanilla-cookieconsent/dist/cookieconsent.css";
2
+ import * as CookieConsent from "vanilla-cookieconsent";
3
+
4
+ import * as settings from "@/logic/settings";
5
+
6
+ export const plugin = {
7
+ install(app: any, pluginConfig: any): void {
8
+ app.config.globalProperties.$CookieConsent = CookieConsent;
9
+ CookieConsent.run(pluginConfig);
10
+ }
11
+ };
12
+
13
+ export function showCookieConsent() {
14
+ CookieConsent.show(true);
15
+ }
16
+
17
+ export function isAnalyticsAllowed(): boolean {
18
+ return CookieConsent.acceptedCategory("analytics");
19
+ }
20
+
21
+ const plausibleId = "plausible-script";
22
+
23
+ function syncPlausible(): void {
24
+ if (!settings.plausibleEnabled) {
25
+ disablePlausible();
26
+ return;
27
+ }
28
+
29
+ if (!isAnalyticsAllowed()) {
30
+ disablePlausible();
31
+ return;
32
+ }
33
+
34
+ enablePlausible();
35
+ }
36
+
37
+ function isPlausibleEnabled() {
38
+ return document.getElementById(plausibleId) !== null;
39
+ }
40
+
41
+ function disablePlausible() {
42
+ if (!isPlausibleEnabled()) {
43
+ return;
44
+ }
45
+
46
+ // The simplest and straightforward way to disable smth is to reload the page
47
+ // We expect that users will not reevaluate the cookie consent modal often
48
+ window.location.reload();
49
+ }
50
+
51
+ function enablePlausible() {
52
+ if (isPlausibleEnabled()) {
53
+ return;
54
+ }
55
+
56
+ console.log("setup Plausible script");
57
+
58
+ const script = document.createElement("script");
59
+ script.id = plausibleId;
60
+ script.src = settings.plausibleScript;
61
+ script.async = true;
62
+ script.defer = true;
63
+ script.setAttribute("data-domain", settings.plausibleDomain);
64
+ document.body.appendChild(script);
65
+ }
66
+
67
+ const _description = `
68
+ <p>We use cookies and local storage for session tracking (required) and optional analytics.</p>
69
+ <p>Please let us collect analytics to better understand how you use us and become the best news reader ever.</p>
70
+ <p>You can find more information in our <a href="/privacy" target="_blank" rel="noopener noreferrer">privacy policy</a>.</p>
71
+ `;
72
+
73
+ export const defaultConfig = {
74
+ revision: 1,
75
+
76
+ onConsent(): void {
77
+ syncPlausible();
78
+ },
79
+
80
+ onChange(): void {
81
+ syncPlausible();
82
+ },
83
+
84
+ categories: {
85
+ necessary: {
86
+ enabled: true,
87
+ readOnly: true
88
+ },
89
+ analytics: {
90
+ enabled: true,
91
+ readOnly: false
92
+ }
93
+ },
94
+
95
+ language: {
96
+ default: "en",
97
+ translations: {
98
+ en: {
99
+ consentModal: {
100
+ title: "We use cookies and local storage",
101
+ description: _description,
102
+ acceptAllBtn: "Accept all",
103
+ acceptNecessaryBtn: "Reject all",
104
+ showPreferencesBtn: "Manage preferences"
105
+ },
106
+ preferencesModal: {
107
+ title: "Manage privacy preferences",
108
+ acceptAllBtn: "Accept all",
109
+ acceptNecessaryBtn: "Reject all",
110
+ savePreferencesBtn: "Accept current selection",
111
+ closeIconLabel: "Close",
112
+ sections: [
113
+ {
114
+ description: _description
115
+ },
116
+ {
117
+ title: "Strictly necessary data",
118
+ description: "This data is essential for the proper functioning of the website and cannot be disabled.",
119
+
120
+ linkedCategory: "necessary",
121
+
122
+ cookieTable: {
123
+ headers: {
124
+ name: "Data",
125
+ description: "Description"
126
+ },
127
+ body: [
128
+ {
129
+ name: "Session information",
130
+ description:
131
+ "We store your session information which is absolutely necessary for the website to work."
132
+ }
133
+ ]
134
+ }
135
+ },
136
+ {
137
+ title: "Performance and analytics",
138
+ description: "These services collect information about how you use our website.",
139
+ linkedCategory: "analytics",
140
+
141
+ cookieTable: {
142
+ headers: {
143
+ name: "Service",
144
+ domain: "Domain",
145
+ description: "Description"
146
+ },
147
+ body: [
148
+ settings.plausibleEnabled
149
+ ? {
150
+ name: "Plausible",
151
+ domain: "plausible.io",
152
+ description:
153
+ "EU-based, cookie-free service that helps us measure traffic and improve usability, without collecting personal data."
154
+ }
155
+ : null,
156
+ {
157
+ name: "Feeds Fun",
158
+ domain: "feeds.fun",
159
+ description:
160
+ "We collect our own analytics to improve the service. We do not share this data with third parties."
161
+ }
162
+ ].filter((x) => x !== null)
163
+ }
164
+ }
165
+ ]
166
+ }
167
+ }
168
+ }
169
+ }
170
+ };
@@ -8,7 +8,9 @@ import DiscoveryView from "../views/DiscoveryView.vue";
8
8
  import CollectionsView from "../views/CollectionsView.vue";
9
9
  import SettingsView from "../views/SettingsView.vue";
10
10
  import PublicCollectionView from "../views/PublicCollectionView.vue";
11
+ import CRMView from "../views/CRMView.vue";
11
12
  import * as e from "@/logic/enums";
13
+ import * as settings from "@/logic/settings";
12
14
 
13
15
  // lazy view loading does not work with router.push function
14
16
  // first attempt to router.push into not loaded view, will cause its loading, but will not change components
@@ -61,6 +63,18 @@ const router = createRouter({
61
63
  name: "public-collection",
62
64
  component: PublicCollectionView
63
65
  },
66
+ {
67
+ path: "/terms",
68
+ name: "terms",
69
+ component: CRMView,
70
+ props: {content: settings.crmTerms, kind: "terms"}
71
+ },
72
+ {
73
+ path: "/privacy",
74
+ name: "privacy",
75
+ component: CRMView,
76
+ props: {content: settings.crmPrivacy, kind: "privacy"}
77
+ },
64
78
  {
65
79
  path: "/:pathMatch(.*)*",
66
80
  redirect: "/"
@@ -10,7 +10,7 @@ export const useGlobalState = defineStore("globalState", () => {
10
10
  const supertokens = useSupertokens();
11
11
 
12
12
  const isLoggedIn = computed(() => {
13
- if (settings.authMode === settings.AuthMode.SingleUser) {
13
+ if (settings.isSingleUserMode) {
14
14
  return true;
15
15
  }
16
16
 
package/src/style.css CHANGED
@@ -1,5 +1,3 @@
1
- @import url("https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css");
2
-
3
1
  @import "tailwindcss/base";
4
2
  @import "tailwindcss/components";
5
3
  @import "tailwindcss/utilities";
@@ -2,9 +2,12 @@
2
2
  <a
3
3
  :href="url"
4
4
  target="_blank"
5
+ class="whitespace-nowrap"
5
6
  rel="noopener noreferrer">
6
7
  {{ renderedText }}
7
- <i class="ti ti-external-link" />
8
+ <icon
9
+ icon="external-link"
10
+ size="small" />
8
11
  </a>
9
12
  </template>
10
13
 
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <component
3
+ :is="componentName"
4
+ class="inline-block"
5
+ v-bind="iconProperties" />
6
+ </template>
7
+
8
+ <script lang="ts" setup>
9
+ import {computed} from "vue";
10
+ import {
11
+ IconArrowNarrowRight,
12
+ IconArrowRight,
13
+ IconPlus,
14
+ IconDots,
15
+ IconBrandReddit,
16
+ IconBrandDiscord,
17
+ IconBrandGithub,
18
+ IconExternalLink,
19
+ IconChevronsLeft,
20
+ IconChevronsRight,
21
+ IconLayoutSidebarLeftCollapse,
22
+ IconLayoutSidebarLeftExpand,
23
+ IconX,
24
+ IconMoodSmile,
25
+ IconMoodSad
26
+ } from "@tabler/icons-vue";
27
+
28
+ const iconMap = {
29
+ reddit: IconBrandReddit,
30
+ discord: IconBrandDiscord,
31
+ github: IconBrandGithub,
32
+ "arrow-narrow-right": IconArrowNarrowRight,
33
+ "arrow-right": IconArrowRight,
34
+ plus: IconPlus,
35
+ dots: IconDots,
36
+ "external-link": IconExternalLink,
37
+ "chevrons-right": IconChevronsRight,
38
+ "chevrons-left": IconChevronsLeft,
39
+ "sidebar-left-collapse": IconLayoutSidebarLeftCollapse,
40
+ "sidebar-left-expand": IconLayoutSidebarLeftExpand,
41
+ x: IconX,
42
+ "face-smile": IconMoodSmile,
43
+ "face-sad": IconMoodSad
44
+ } as const;
45
+
46
+ type IconName = keyof typeof iconMap;
47
+
48
+ const sizeMap = {
49
+ small: 16,
50
+ medium: 20,
51
+ large: 24
52
+ } as const;
53
+
54
+ type IconSize = keyof typeof sizeMap;
55
+
56
+ const properties = defineProps<{icon: IconName; size?: IconSize}>();
57
+
58
+ const componentName = computed(() => {
59
+ if (properties.icon in iconMap) {
60
+ return iconMap[properties.icon];
61
+ } else {
62
+ throw new Error(`Icon ${properties.icon} not found`);
63
+ }
64
+ });
65
+
66
+ const iconProperties = computed(() => {
67
+ return {
68
+ size: sizeMap[properties.size ?? "medium"]
69
+ };
70
+ });
71
+ </script>
@@ -0,0 +1,81 @@
1
+ <template>
2
+ <a
3
+ v-if="link.enabled"
4
+ :href="link.url"
5
+ target="_blank"
6
+ @click="events.socialLinkClicked({linkType: link.eventType, view: eventsView})">
7
+ <span v-if="mode === 'text'">
8
+ {{ link.text }}
9
+
10
+ <icon
11
+ icon="external-link"
12
+ size="small" />
13
+ </span>
14
+
15
+ <icon
16
+ v-else
17
+ :icon="link.icon" />
18
+ </a>
19
+ </template>
20
+
21
+ <script lang="ts" setup>
22
+ import {inject, computed} from "vue";
23
+
24
+ import * as events from "@/logic/events";
25
+ import * as settings from "@/logic/settings";
26
+ import * as asserts from "@/logic/asserts";
27
+
28
+ const links = {
29
+ api: {
30
+ enabled: true,
31
+ url: "/api/docs",
32
+ text: "API",
33
+ icon: null,
34
+ eventType: "api"
35
+ },
36
+ blog: {
37
+ enabled: settings.blog !== null,
38
+ url: settings.blog,
39
+ text: "Blog",
40
+ icon: null,
41
+ eventType: "blog"
42
+ },
43
+ reddit: {
44
+ enabled: settings.redditSubreddit !== null,
45
+ url: settings.redditSubreddit,
46
+ text: "Reddit",
47
+ icon: "reddit",
48
+ eventType: "reddit"
49
+ },
50
+ discord: {
51
+ enabled: settings.discordInvite !== null,
52
+ url: settings.discordInvite,
53
+ text: "Discord",
54
+ icon: "discord",
55
+ eventType: "discord"
56
+ },
57
+ github: {
58
+ enabled: settings.githubRepo !== null,
59
+ url: settings.githubRepo,
60
+ text: "GitHub",
61
+ icon: "github",
62
+ eventType: "github"
63
+ }
64
+ };
65
+
66
+ type LinkKind = keyof typeof links;
67
+
68
+ const properties = defineProps<{kind: LinkKind; mode: "icon" | "text"}>();
69
+
70
+ const eventsView = inject<events.EventsViewName>("eventsViewName");
71
+
72
+ asserts.defined(eventsView);
73
+
74
+ const link = computed(() => {
75
+ if (properties.kind in links) {
76
+ return links[properties.kind];
77
+ }
78
+
79
+ throw new Error(`Link kind "${properties.kind}" not found`);
80
+ });
81
+ </script>
@@ -1,12 +1,23 @@
1
1
  <template>
2
2
  <wide-layout>
3
- <template #header> Feeds Fun </template>
3
+ <div class="ffun-page-header">
4
+ <div class="ffun-page-header-center-block">
5
+ <page-header-external-links :show-api="false" />
6
+ </div>
7
+ </div>
4
8
 
5
- <div class="ffun-info-good">
6
- <p v-if="!linkProcessed">Checking login status...</p>
9
+ <hr />
7
10
 
8
- <supertokens-login v-else />
9
- </div>
11
+ <main-block>
12
+ <h1 class="m-0 text-5xl">Feeds Fun</h1>
13
+ <p class="mt-2 text-2xl">Transparent Personalized News</p>
14
+ </main-block>
15
+
16
+ <main-block>
17
+ <div class="max-w-xl md:mx-auto ffun-info-good text-center mx-2">
18
+ <p>Checking login status...</p>
19
+ </div>
20
+ </main-block>
10
21
  </wide-layout>
11
22
  </template>
12
23
 
@@ -28,12 +39,14 @@
28
39
 
29
40
  provide("eventsViewName", "auth");
30
41
 
31
- const linkProcessed = ref(false);
32
-
33
42
  function goToWorkspace() {
34
43
  router.push({name: globalSettings.mainPanelMode, params: {}});
35
44
  }
36
45
 
46
+ function goToMain() {
47
+ router.push({name: "main", params: {}});
48
+ }
49
+
37
50
  onBeforeMount(async () => {
38
51
  if (globalState.isLoggedIn) {
39
52
  goToWorkspace();
@@ -46,6 +59,7 @@
46
59
 
47
60
  async function onSignFailed() {
48
61
  await supertokens.clearLoginAttempt();
62
+ goToMain();
49
63
  }
50
64
 
51
65
  if (await supertokens.hasInitialMagicLinkBeenSent()) {
@@ -54,9 +68,8 @@
54
68
  onSignIn: onSignIn,
55
69
  onSignFailed: onSignFailed
56
70
  });
57
- linkProcessed.value = true;
58
71
  } else {
59
- linkProcessed.value = true;
72
+ goToMain();
60
73
  }
61
74
  });
62
75
  </script>
@@ -0,0 +1,41 @@
1
+ <template>
2
+ <wide-layout>
3
+ <div class="ffun-page-header">
4
+ <div class="ffun-page-header-center-block">
5
+ <page-header-home-button />
6
+ <page-header-external-links :show-api="false" />
7
+ </div>
8
+ </div>
9
+
10
+ <hr />
11
+
12
+ <main-block>
13
+ <h1 class="m-0 text-5xl">Feeds Fun</h1>
14
+ <p class="mt-2 text-2xl">Transparent Personalized News</p>
15
+ </main-block>
16
+
17
+ <hr />
18
+
19
+ <main-block>
20
+ <div
21
+ v-html="actualContent"
22
+ class="prose max-w-none" />
23
+ </main-block>
24
+ </wide-layout>
25
+ </template>
26
+
27
+ <script lang="ts" setup>
28
+ import {computed, provide} from "vue";
29
+
30
+ const properties = defineProps<{content: string | null; kind: string}>();
31
+
32
+ provide("eventsViewName", properties.kind);
33
+
34
+ const actualContent = computed(() => {
35
+ if (properties.content === null) {
36
+ return "You MUST define a content for this page in case you host public version of Feeds Fun.";
37
+ }
38
+
39
+ return properties.content;
40
+ });
41
+ </script>