mirror of
https://github.com/LemmyNet/joinlemmy-site.git
synced 2024-11-25 13:51:17 +00:00
Starting to add filters.
This commit is contained in:
parent
1ad0188eec
commit
c59234a7f7
8 changed files with 226 additions and 136 deletions
|
@ -1 +1 @@
|
|||
Subproject commit b95dfdad19b866e86ed810d6f79c99e13066671e
|
||||
Subproject commit afcb1684e80a42a6fecd6c0cebdf4caa64f92440
|
|
@ -1 +1 @@
|
|||
Subproject commit 0a5b9790fbae41e8dc51021c2d20ec0f5743b1d8
|
||||
Subproject commit b9a566d8376c1ca4d122d63cb921accc3db6c79f
|
|
@ -2,6 +2,8 @@
|
|||
"recommended": [
|
||||
"lemmy.ml",
|
||||
"lemm.ee",
|
||||
"lemmy.world",
|
||||
"feddit.it",
|
||||
"lemmygrad.ml",
|
||||
"reddthat.com",
|
||||
"hexbear.net",
|
||||
|
|
|
@ -15,6 +15,13 @@ export const BACKGROUND_GRADIENT_1 =
|
|||
export const BACKGROUND_GRADIENT_2 =
|
||||
"min-h-full bg-gradient-to-b from-transparent to-black/[.30] to-20%";
|
||||
|
||||
export const SELECT_CLASSES =
|
||||
"select select-sm select-ghost select-bordered text-gray-400";
|
||||
|
||||
export function languageList() {
|
||||
return Object.keys(i18n.services.resourceStore.data).sort();
|
||||
}
|
||||
|
||||
export const Badge = ({ content, outline = false }) => (
|
||||
<div
|
||||
className={classNames("p-2 rounded-xl bg-neutral-800 text-gray-300 w-fit", {
|
||||
|
|
|
@ -31,99 +31,121 @@ export const INSTANCE_HELPERS: InstanceHelper[] = [
|
|||
];
|
||||
|
||||
// TODO add i18n strings, Icons
|
||||
export enum InstanceCategory {
|
||||
Tech,
|
||||
Sports,
|
||||
// DO this as an interface and const list
|
||||
|
||||
export interface Category {
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const All_CATEGORY: Category = {
|
||||
name: "all",
|
||||
icon: "TBD",
|
||||
};
|
||||
|
||||
export const SPORTS: Category = {
|
||||
name: "sports",
|
||||
icon: "TBD",
|
||||
};
|
||||
|
||||
export const TECH: Category = {
|
||||
name: "tech",
|
||||
icon: "TBD",
|
||||
};
|
||||
|
||||
export const CATEGORIES: Category[] = [All_CATEGORY, TECH, SPORTS];
|
||||
|
||||
export interface RecommendedInstance {
|
||||
domain: string;
|
||||
languages: string[];
|
||||
categories: InstanceCategory[];
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
// TODO fix these up
|
||||
export const RECOMMENDED_INSTANCES: RecommendedInstance[] = [
|
||||
{
|
||||
domain: "lemmy.ml",
|
||||
languages: ["en"],
|
||||
categories: [TECH],
|
||||
},
|
||||
{
|
||||
domain: "lemmy.world",
|
||||
languages: ["en"],
|
||||
categories: [TECH],
|
||||
},
|
||||
{
|
||||
domain: "lemmy.fmhy.ml",
|
||||
languages: ["en"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
{
|
||||
domain: "discuss.tchncs.de",
|
||||
languages: ["en"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
{
|
||||
domain: "lemm.ee",
|
||||
languages: ["en"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
{
|
||||
domain: "reddthat.com",
|
||||
languages: ["en"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
{
|
||||
domain: "discuss.online",
|
||||
languages: ["en"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
{
|
||||
domain: "feddit.dk",
|
||||
languages: ["da"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
{
|
||||
domain: "feddit.de",
|
||||
languages: ["de"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
|
||||
{
|
||||
domain: "discuss.tchncs.de",
|
||||
languages: ["de"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
{
|
||||
domain: "feddit.nl",
|
||||
languages: ["nl"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
|
||||
{
|
||||
domain: "lemmy.pt",
|
||||
languages: ["pt", "pt-PT", "pt-BR"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
},
|
||||
|
||||
{
|
||||
domain: "lemmy.pt",
|
||||
languages: ["pt", "pt-PT", "pt-BR"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
languages: ["pt"],
|
||||
categories: [TECH],
|
||||
},
|
||||
|
||||
{
|
||||
domain: "lemmy.eus",
|
||||
languages: ["eu"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
|
||||
{
|
||||
domain: "tabinezumi.net",
|
||||
languages: ["ja"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
|
||||
{
|
||||
domain: "lm.korako.me",
|
||||
languages: ["ja"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
|
||||
{
|
||||
domain: "feddit.it",
|
||||
languages: ["it"],
|
||||
categories: [InstanceCategory.Tech],
|
||||
categories: [TECH],
|
||||
},
|
||||
];
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
import { Component, InfernoEventHandler } from "inferno";
|
||||
import { Component, InfernoEventHandler, linkEvent } from "inferno";
|
||||
import { Helmet } from "inferno-helmet";
|
||||
import { i18n } from "../i18next";
|
||||
import { i18n, LANGUAGES } from "../i18next";
|
||||
import { T } from "inferno-i18next";
|
||||
import { instance_stats } from "../instance_stats";
|
||||
import { mdToHtml, numToSI } from "../utils";
|
||||
import { Badge, TEXT_GRADIENT } from "./common";
|
||||
import { INSTANCE_HELPERS } from "./instances-definitions";
|
||||
import { Badge, SELECT_CLASSES, TEXT_GRADIENT, languageList } from "./common";
|
||||
import {
|
||||
INSTANCE_HELPERS,
|
||||
Category,
|
||||
RECOMMENDED_INSTANCES,
|
||||
All_CATEGORY,
|
||||
CATEGORIES,
|
||||
} from "./instances-definitions";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
const TitleBlock = () => (
|
||||
|
@ -58,15 +64,15 @@ interface InstanceCardGridProps {
|
|||
instances: any[];
|
||||
}
|
||||
|
||||
const InstanceCardGrid = ({ title, instances }: InstanceCardGridProps) => (
|
||||
<div className="my-16">
|
||||
<SectionTitle title={title} />
|
||||
// TODO create the instance picker helper
|
||||
|
||||
// - Language, Categories, and Sort Order (active, random)
|
||||
const InstanceCardGrid = ({ instances }: InstanceCardGridProps) => (
|
||||
<div className="grid md:grid-cols-3 grid-cols-1 gap-4">
|
||||
{instances.map(i => (
|
||||
<InstanceCard instance={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface InstanceCardProps {
|
||||
|
@ -238,77 +244,98 @@ export const DetailsModal = ({
|
|||
</dialog>
|
||||
);
|
||||
|
||||
function biasedRandom(activeUsers: number, avg: number, max: number): number {
|
||||
// Lets introduce a better bias to random shuffle instances list
|
||||
const influence = 1.25;
|
||||
const rnd = Math.random() * (max / influence) + activeUsers;
|
||||
const mix = Math.random() * influence;
|
||||
return rnd * (1 - mix) + avg * mix;
|
||||
interface Sort {
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
function average(arr: number[]): number {
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
const RANDOM_SORT: Sort = {
|
||||
name: "random",
|
||||
icon: "TBD",
|
||||
};
|
||||
|
||||
interface InstanceList {
|
||||
recommended: any[];
|
||||
popular: any[];
|
||||
}
|
||||
const MOST_ACTIVE_SORT: Sort = {
|
||||
name: "most_active",
|
||||
icon: "TBD",
|
||||
};
|
||||
|
||||
function buildInstanceList(): InstanceList {
|
||||
const recommendedList =
|
||||
instance_stats.recommended[i18n.language] ??
|
||||
instance_stats.recommended["en"];
|
||||
const LEAST_ACTIVE_SORT: Sort = {
|
||||
name: "least_active",
|
||||
icon: "TBD",
|
||||
};
|
||||
|
||||
let recommended = [];
|
||||
let popular = [];
|
||||
let usersActiveMonth = [];
|
||||
const SORTS: Sort[] = [RANDOM_SORT, MOST_ACTIVE_SORT, LEAST_ACTIVE_SORT];
|
||||
|
||||
// Loop over all the instances, and add them to the two lists
|
||||
for (const i of instance_stats.stats.instance_details) {
|
||||
if (recommendedList.indexOf(i.domain) > -1) {
|
||||
recommended.push(i);
|
||||
} else {
|
||||
popular.push(i);
|
||||
}
|
||||
|
||||
usersActiveMonth.push(i.site_info.site_view.counts.users_active_month);
|
||||
}
|
||||
|
||||
// Use these values for the shuffle
|
||||
const avgMonthlyUsers = average(usersActiveMonth);
|
||||
const maxMonthlyUsers = Math.max(...usersActiveMonth);
|
||||
|
||||
// Randomize the recommended
|
||||
recommended = recommended
|
||||
function sortRandom(instances: any[]): any[] {
|
||||
return instances
|
||||
.map(value => ({ value, sort: Math.random() }))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.map(({ value }) => value);
|
||||
|
||||
// BIASED sorting for instances, based on the min/max of users_active_month
|
||||
popular = popular
|
||||
.map(i => ({
|
||||
instance: i,
|
||||
sort: biasedRandom(
|
||||
i.site_info.site_view.counts.users_active_month,
|
||||
avgMonthlyUsers,
|
||||
maxMonthlyUsers,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => b.sort - a.sort)
|
||||
.map(({ instance }) => instance);
|
||||
|
||||
return { recommended, popular };
|
||||
}
|
||||
|
||||
export class Instances extends Component<any, any> {
|
||||
function sortActive(instances: any[]): any[] {
|
||||
return instances.sort(
|
||||
(a, b) =>
|
||||
b.site_info.site_view.counts.users_active_month -
|
||||
a.site_info.site_view.counts.users_active_month,
|
||||
);
|
||||
}
|
||||
|
||||
interface State {
|
||||
sort: Sort;
|
||||
language: string;
|
||||
category: Category;
|
||||
}
|
||||
|
||||
export class Instances extends Component<any, State> {
|
||||
state: State = {
|
||||
sort: SORTS[0],
|
||||
language: i18n.language.split("-")[0],
|
||||
category: All_CATEGORY,
|
||||
};
|
||||
|
||||
constructor(props: any, context: any) {
|
||||
super(props, context);
|
||||
}
|
||||
|
||||
buildInstanceList(): any[] {
|
||||
let instances = instance_stats.stats.instance_details;
|
||||
const recommended = RECOMMENDED_INSTANCES;
|
||||
|
||||
// Language Filter
|
||||
if (this.state.language !== "all") {
|
||||
const languageRecs = recommended.filter(r =>
|
||||
r.languages.includes(this.state.language),
|
||||
);
|
||||
instances = instances.filter(i =>
|
||||
languageRecs.map(r => r.domain).includes(i.domain),
|
||||
);
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (this.state.category !== All_CATEGORY) {
|
||||
const categoryRecs = recommended.filter(r =>
|
||||
r.categories.includes(this.state.category),
|
||||
);
|
||||
instances = instances.filter(i =>
|
||||
categoryRecs.map(c => c.domain).includes(i.domain),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (this.state.sort == SORTS[0]) {
|
||||
instances = sortRandom(instances);
|
||||
} else if (this.state.sort == SORTS[1]) {
|
||||
instances = sortActive(instances);
|
||||
} else {
|
||||
instances = sortActive(instances).reverse();
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
render() {
|
||||
const title = i18n.t("join_title");
|
||||
const instances = buildInstanceList();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
|
@ -317,49 +344,84 @@ export class Instances extends Component<any, any> {
|
|||
</Helmet>
|
||||
<TitleBlock />
|
||||
<ComparisonBlock />
|
||||
<InstanceCardGrid
|
||||
title={i18n.t("recommended_instances")}
|
||||
instances={instances.recommended}
|
||||
/>
|
||||
{this.filterAndTitleBlock()}
|
||||
<InstanceCardGrid
|
||||
title={i18n.t("popular_instances")}
|
||||
instances={instances.popular}
|
||||
instances={this.buildInstanceList()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderList(header: string, instances: any[]) {
|
||||
// TODO i18n these
|
||||
filterAndTitleBlock() {
|
||||
return (
|
||||
<div>
|
||||
<h2>{header}</h2>
|
||||
<div class="row">
|
||||
{instances.map(instance => {
|
||||
let domain = instance.domain;
|
||||
let description = instance.site_info.site_view.site.description;
|
||||
let icon = instance.site_info.site_view.site.icon;
|
||||
return (
|
||||
<div class="card col-6">
|
||||
<header>
|
||||
<div class="row">
|
||||
<h4 class="col">{domain}</h4>
|
||||
<div className="my-16">
|
||||
<div className="flex flex-row flex-wrap gap-4">
|
||||
<div className="flex-none">
|
||||
<SectionTitle title={i18n.t("join_title")} />
|
||||
</div>
|
||||
</header>
|
||||
<div class="is-center">
|
||||
<img class="join-banner" src={icon} onError={imgError} />
|
||||
<div className="grow"></div>
|
||||
<div className="flex-none">
|
||||
<select
|
||||
className={`${SELECT_CLASSES} mr-2`}
|
||||
value={this.state.category.name}
|
||||
onChange={linkEvent(this, handleCategoryChange)}
|
||||
name="category_select"
|
||||
>
|
||||
<option disabled selected>
|
||||
Category
|
||||
</option>
|
||||
{CATEGORIES.map(c => (
|
||||
<option key={c.name} value={c.name}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={this.state.language}
|
||||
onChange={linkEvent(this, handleLanguageChange)}
|
||||
className={`${SELECT_CLASSES} mr-2`}
|
||||
>
|
||||
<option disabled>Languages</option>
|
||||
<option key="all" value="all">
|
||||
all
|
||||
</option>
|
||||
{languageList().map((language, i) => (
|
||||
<option key={i} value={language}>
|
||||
{LANGUAGES.find(l => l.code.startsWith(language)).name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={this.state.sort.name}
|
||||
name="sort_select"
|
||||
className={SELECT_CLASSES}
|
||||
onChange={linkEvent(this, handleSortChange)}
|
||||
>
|
||||
<option disabled>Sort TODO</option>
|
||||
{SORTS.map(s => (
|
||||
<option key={s.name} value={s.name}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<br />
|
||||
<p class="join-desc">{description}</p>
|
||||
<footer>
|
||||
<a class="button primary" href={`https://${domain}`}>
|
||||
{i18n.t("browse_instance")}
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSortChange(i: Instances, event: any) {
|
||||
i.setState({ sort: SORTS.find(s => s.name == event.target.value) });
|
||||
}
|
||||
|
||||
function handleCategoryChange(i: Instances, event: any) {
|
||||
i.setState({ category: CATEGORIES.find(c => c.name == event.target.value) });
|
||||
}
|
||||
|
||||
function handleLanguageChange(i: Instances, event: any) {
|
||||
i.setState({ language: event.target.value });
|
||||
}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { ChangeEvent, linkEvent } from "inferno";
|
||||
import { Link } from "inferno-router";
|
||||
import { Icon, IconSize } from "./icon";
|
||||
import { i18n, languages } from "../i18next";
|
||||
import { i18n, LANGUAGES } from "../i18next";
|
||||
import classNames from "classnames";
|
||||
import { SELECT_CLASSES, languageList } from "./common";
|
||||
|
||||
const NavLink = ({ content }) => <li className="text-gray-400">{content}</li>;
|
||||
|
||||
|
@ -17,10 +18,6 @@ const NavLinks = () => (
|
|||
</>
|
||||
);
|
||||
|
||||
function languageList() {
|
||||
return Object.keys(i18n.services.resourceStore.data).sort();
|
||||
}
|
||||
|
||||
function handleLanguageChange(_: any, event: ChangeEvent<HTMLSelectElement>) {
|
||||
location.href = `/?lang=${event.target.value}`;
|
||||
}
|
||||
|
@ -52,7 +49,7 @@ export const Navbar = ({ footer = false }) => (
|
|||
<>
|
||||
<select
|
||||
onChange={linkEvent(this, handleLanguageChange)}
|
||||
class="select select-sm select-ghost select-bordered text-gray-400"
|
||||
className={SELECT_CLASSES}
|
||||
>
|
||||
{languageList().map((language, i) => (
|
||||
<option
|
||||
|
@ -60,7 +57,7 @@ export const Navbar = ({ footer = false }) => (
|
|||
value={language}
|
||||
selected={i18n.language.startsWith(language)}
|
||||
>
|
||||
{languages.find(l => l.code.startsWith(language)).name}
|
||||
{LANGUAGES.find(l => l.code.startsWith(language)).name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
|
@ -25,7 +25,7 @@ import { pt_BR } from "./translations/pt_BR";
|
|||
import { ru } from "./translations/ru";
|
||||
import { zh } from "./translations/zh";
|
||||
|
||||
export const languages = [
|
||||
export const LANGUAGES = [
|
||||
{ resource: bg, code: "bg", name: "Български" },
|
||||
{ resource: da, code: "da", name: "Dansk" },
|
||||
{ resource: de, code: "de", name: "Deutsch" },
|
||||
|
@ -52,7 +52,7 @@ export const languages = [
|
|||
];
|
||||
|
||||
const resources: Resource = {};
|
||||
languages.forEach(l => (resources[l.code] = l.resource));
|
||||
LANGUAGES.forEach(l => (resources[l.code] = l.resource));
|
||||
|
||||
function format(value: any, format: any): any {
|
||||
return format === "uppercase" ? value.toUpperCase() : value;
|
||||
|
|
Loading…
Reference in a new issue