Merge branch 'main' into main

This commit is contained in:
Ivo Barros 2023-06-21 20:45:58 +01:00 committed by GitHub
commit 0e052602ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 453 additions and 400 deletions

View file

@ -1,3 +1,3 @@
# Contributing # Contributing
See [here](https://join-lemmy.org/docs/en/contributors/01-overview.html) for contributing Instructions. See [here](https://join-lemmy.org/docs/contributors/01-overview.html) for contributing Instructions.

@ -1 +1 @@
Subproject commit 7fc71d0860bbe5c6d620ec27112350ffe5b9229c Subproject commit a241fe1255a6363c7ae1ec5a09520c066745e6ce

View file

@ -1,6 +1,6 @@
{ {
"name": "lemmy-ui", "name": "lemmy-ui",
"version": "0.18.0-rc.3", "version": "0.18.0-rc.4",
"description": "An isomorphic UI for lemmy", "description": "An isomorphic UI for lemmy",
"repository": "https://github.com/LemmyNet/lemmy-ui", "repository": "https://github.com/LemmyNet/lemmy-ui",
"license": "AGPL-3.0", "license": "AGPL-3.0",
@ -22,16 +22,9 @@
"translations:update": "git submodule update --remote --recursive" "translations:update": "git submodule update --remote --recursive"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js}": [ "*.{ts,tsx,js}": ["prettier --write", "eslint --fix"],
"prettier --write", "*.{css, scss}": ["prettier --write"],
"eslint --fix" "package.json": ["sortpack"]
],
"*.{css, scss}": [
"prettier --write"
],
"package.json": [
"sortpack"
]
}, },
"dependencies": { "dependencies": {
"@babel/plugin-proposal-decorators": "^7.21.0", "@babel/plugin-proposal-decorators": "^7.21.0",
@ -66,7 +59,7 @@
"inferno-server": "^8.1.1", "inferno-server": "^8.1.1",
"isomorphic-cookie": "^1.2.4", "isomorphic-cookie": "^1.2.4",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lemmy-js-client": "0.18.0-rc.1", "lemmy-js-client": "0.18.0-rc.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0", "markdown-it-container": "^3.0.0",

View file

@ -28,7 +28,9 @@ export default async (req: Request, res: Response) => {
const getSiteForm: GetSite = { auth }; const getSiteForm: GetSite = { auth };
const headers = setForwardedHeaders(req.headers); const headers = setForwardedHeaders(req.headers);
const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers)); const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { fetchFunction: fetch, headers })
);
const { path, url, query } = req; const { path, url, query } = req;

View file

@ -0,0 +1,30 @@
import type { Request, Response } from "express";
import { LemmyHttp } from "lemmy-js-client";
import { getHttpBaseInternal } from "../../shared/env";
import { wrapClient } from "../../shared/services/HttpService";
import generateManifestJson from "../utils/generate-manifest-json";
import { setForwardedHeaders } from "../utils/set-forwarded-headers";
let manifest: Awaited<ReturnType<typeof generateManifestJson>> | undefined =
undefined;
export default async (req: Request, res: Response) => {
if (!manifest) {
const headers = setForwardedHeaders(req.headers);
const client = wrapClient(
new LemmyHttp(getHttpBaseInternal(), { fetchFunction: fetch, headers })
);
const site = await client.getSite({});
if (site.state === "success") {
manifest = await generateManifestJson(site.data);
} else {
res.sendStatus(500);
return;
}
}
res.setHeader("content-type", "application/manifest+json");
res.send(manifest);
};

View file

@ -15,7 +15,7 @@ export default async (req: Request, res: Response) => {
res.send("Theme must be a css file"); res.send("Theme must be a css file");
} }
const customTheme = path.resolve(`./${extraThemesFolder}/${theme}`); const customTheme = path.resolve(extraThemesFolder, theme);
if (existsSync(customTheme)) { if (existsSync(customTheme)) {
res.sendFile(customTheme); res.sendFile(customTheme);

View file

@ -2,6 +2,7 @@ import express from "express";
import path from "path"; import path from "path";
import process from "process"; import process from "process";
import CatchAllHandler from "./handlers/catch-all-handler"; import CatchAllHandler from "./handlers/catch-all-handler";
import ManifestHandler from "./handlers/manifest-handler";
import RobotsHandler from "./handlers/robots-handler"; import RobotsHandler from "./handlers/robots-handler";
import ServiceWorkerHandler from "./handlers/service-worker-handler"; import ServiceWorkerHandler from "./handlers/service-worker-handler";
import ThemeHandler from "./handlers/theme-handler"; import ThemeHandler from "./handlers/theme-handler";
@ -24,6 +25,7 @@ if (!process.env["LEMMY_UI_DISABLE_CSP"] && !process.env["LEMMY_UI_DEBUG"]) {
server.get("/robots.txt", RobotsHandler); server.get("/robots.txt", RobotsHandler);
server.get("/service-worker.js", ServiceWorkerHandler); server.get("/service-worker.js", ServiceWorkerHandler);
server.get("/manifest", ManifestHandler);
server.get("/css/themes/:name", ThemeHandler); server.get("/css/themes/:name", ThemeHandler);
server.get("/css/themelist", ThemesListHandler); server.get("/css/themelist", ThemesListHandler);
server.get("/*", CatchAllHandler); server.get("/*", CatchAllHandler);

View file

@ -5,32 +5,35 @@ import sharp from "sharp";
import { ILemmyConfig, IsoDataOptionalSite } from "../../shared/interfaces"; import { ILemmyConfig, IsoDataOptionalSite } from "../../shared/interfaces";
import { favIconPngUrl, favIconUrl } from "../../shared/utils"; import { favIconPngUrl, favIconUrl } from "../../shared/utils";
import { fetchIconPng } from "./fetch-icon-png"; import { fetchIconPng } from "./fetch-icon-png";
import { generateManifestBase64 } from "./generate-manifest-base64";
const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || ""; const customHtmlHeader = process.env["LEMMY_UI_CUSTOM_HTML_HEADER"] || "";
let appleTouchIcon: string | undefined = undefined;
export async function createSsrHtml( export async function createSsrHtml(
root: string, root: string,
isoData: IsoDataOptionalSite isoData: IsoDataOptionalSite
) { ) {
const site = isoData.site_res; const site = isoData.site_res;
const appleTouchIcon = site?.site_view.site.icon if (!appleTouchIcon) {
? `data:image/png;base64,${sharp( appleTouchIcon = site?.site_view.site.icon
await fetchIconPng(site.site_view.site.icon) ? `data:image/png;base64,${sharp(
) await fetchIconPng(site.site_view.site.icon)
.resize(180, 180) )
.extend({ .resize(180, 180)
bottom: 20, .extend({
top: 20, bottom: 20,
left: 20, top: 20,
right: 20, left: 20,
background: "#222222", right: 20,
}) background: "#222222",
.png() })
.toBuffer() .png()
.then(buf => buf.toString("base64"))}` .toBuffer()
: favIconPngUrl; .then(buf => buf.toString("base64"))}`
: favIconPngUrl;
}
const erudaStr = const erudaStr =
process.env["LEMMY_UI_DEBUG"] === "true" process.env["LEMMY_UI_DEBUG"] === "true"
@ -74,15 +77,7 @@ export async function createSsrHtml(
/> />
<!-- Web app manifest --> <!-- Web app manifest -->
${ <link rel="manifest" href="/manifest" />
site &&
`<link
rel="manifest"
href=${`data:application/manifest+json;base64,${await generateManifestBase64(
site
)}`}
/>`
}
<link rel="apple-touch-icon" href=${appleTouchIcon} /> <link rel="apple-touch-icon" href=${appleTouchIcon} />
<link rel="apple-touch-startup-image" href=${appleTouchIcon} /> <link rel="apple-touch-startup-image" href=${appleTouchIcon} />

View file

@ -5,7 +5,7 @@ import sharp from "sharp";
import { getHttpBaseExternal } from "../../shared/env"; import { getHttpBaseExternal } from "../../shared/env";
import { fetchIconPng } from "./fetch-icon-png"; import { fetchIconPng } from "./fetch-icon-png";
const iconSizes = [72, 96, 144, 192, 512]; const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512];
const defaultLogoPathDirectory = path.join( const defaultLogoPathDirectory = path.join(
process.cwd(), process.cwd(),
@ -14,7 +14,7 @@ const defaultLogoPathDirectory = path.join(
"icons" "icons"
); );
export async function generateManifestBase64({ export default async function ({
my_user, my_user,
site_view: { site_view: {
site, site,
@ -25,7 +25,7 @@ export async function generateManifestBase64({
const icon = site.icon ? await fetchIconPng(site.icon) : null; const icon = site.icon ? await fetchIconPng(site.icon) : null;
const manifest = { return {
name: site.name, name: site.name,
description: site.description ?? "A link aggregator for the fediverse", description: site.description ?? "A link aggregator for the fediverse",
start_url: url, start_url: url,
@ -69,31 +69,24 @@ export async function generateManifestBase64({
short_name: "Communities", short_name: "Communities",
description: "Browse communities", description: "Browse communities",
}, },
] {
.concat( name: "Create Post",
my_user url: "/create_post",
? [ short_name: "Create Post",
{ description: "Create a post.",
name: "Create Post", },
url: "/create_post", ].concat(
short_name: "Create Post", my_user?.local_user_view.person.admin || !community_creation_admin_only
description: "Create a post.", ? [
}, {
] name: "Create Community",
: [] url: "/create_community",
) short_name: "Create Community",
.concat( description: "Create a community",
my_user?.local_user_view.person.admin || !community_creation_admin_only },
? [ ]
{ : []
name: "Create Community", ),
url: "/create_community",
short_name: "Create Community",
description: "Create a community",
},
]
: []
),
related_applications: [ related_applications: [
{ {
platform: "f-droid", platform: "f-droid",
@ -102,6 +95,4 @@ export async function generateManifestBase64({
}, },
], ],
}; };
return Buffer.from(JSON.stringify(manifest)).toString("base64");
} }

View file

@ -35,7 +35,11 @@ interface NavbarState {
} }
function handleCollapseClick(i: Navbar) { function handleCollapseClick(i: Navbar) {
if (i.collapseButtonRef.current?.ariaExpanded === "true") { if (
i.collapseButtonRef.current?.attributes &&
i.collapseButtonRef.current?.attributes.getNamedItem("aria-expanded")
?.value === "true"
) {
i.collapseButtonRef.current?.click(); i.collapseButtonRef.current?.click();
} }
} }
@ -76,12 +80,8 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
document.removeEventListener("mouseup", this.handleOutsideMenuClick); document.removeEventListener("mouseup", this.handleOutsideMenuClick);
} }
// TODO class active corresponding to current pages
render() { render() {
return this.navbar();
}
// TODO class active corresponding to current page
navbar() {
const siteView = this.props.siteRes?.site_view; const siteView = this.props.siteRes?.site_view;
const person = UserService.Instance.myUserInfo?.local_user_view.person; const person = UserService.Instance.myUserInfo?.local_user_view.person;
return ( return (

View file

@ -66,10 +66,9 @@ export class LanguageSelect extends Component<LanguageSelectProps, any> {
{i18n.t(this.props.multiple ? "language_plural" : "language")} {i18n.t(this.props.multiple ? "language_plural" : "language")}
</label> </label>
<div <div
className={classNames( className={classNames(`col-sm-${this.props.multiple ? 9 : 10}`, {
"input-group", "input-group": this.props.multiple,
`col-sm-${this.props.multiple ? 9 : 10}` })}
)}
> >
{this.selectBtn} {this.selectBtn}
{this.props.multiple && ( {this.props.multiple && (

View file

@ -1,8 +1,9 @@
import classNames from "classnames";
import { Component, InfernoNode, linkEvent } from "inferno"; import { Component, InfernoNode, linkEvent } from "inferno";
interface TabItem { interface TabItem {
key: string; key: string;
getNode: () => InfernoNode; getNode: (isSelected: boolean) => InfernoNode;
label: string; label: string;
} }
@ -30,24 +31,33 @@ export default class Tabs extends Component<TabsProps, TabsState> {
render() { render() {
return ( return (
<div> <div>
<ul className="nav nav-tabs mb-2"> <ul className="nav nav-tabs mb-2" role="tablist">
{this.props.tabs.map(({ key, label }) => ( {this.props.tabs.map(({ key, label }) => (
<li key={key} className="nav-item"> <li key={key} className="nav-item">
<button <button
type="button" type="button"
className={`nav-link btn${ className={classNames("nav-link", {
this.state?.currentTab === key ? " active" : "" active: this.state?.currentTab === key,
}`} })}
onClick={linkEvent({ ctx: this, tab: key }, handleSwitchTab)} onClick={linkEvent({ ctx: this, tab: key }, handleSwitchTab)}
aria-controls={`${key}-tab-pane`}
{...(this.state?.currentTab === key && {
...{
"aria-current": "page",
"aria-selected": "true",
},
})}
> >
{label} {label}
</button> </button>
</li> </li>
))} ))}
</ul> </ul>
{this.props.tabs <div className="tab-content">
.find(tab => tab.key === this.state?.currentTab) {this.props.tabs.map(({ key, getNode }) => {
?.getNode()} return getNode(this.state?.currentTab === key);
})}
</div>
</div> </div>
); );
} }

View file

@ -100,19 +100,17 @@ export class Communities extends Component<any, CommunitiesState> {
const { listingType, page } = this.getCommunitiesQueryParams(); const { listingType, page } = this.getCommunitiesQueryParams();
return ( return (
<div> <div>
<div className="row"> <h1 className="h4">{i18n.t("list_of_communities")}</h1>
<div className="col-md-6"> <div className="row g-2 justify-content-between">
<h4>{i18n.t("list_of_communities")}</h4> <div className="col-auto">
<span className="mb-2"> <ListingTypeSelect
<ListingTypeSelect type_={listingType}
type_={listingType} showLocal={showLocal(this.isoData)}
showLocal={showLocal(this.isoData)} showSubscribed
showSubscribed onChange={this.handleListingTypeChange}
onChange={this.handleListingTypeChange} />
/>
</span>
</div> </div>
<div className="col-md-6">{this.searchForm()}</div> <div className="col-auto">{this.searchForm()}</div>
</div> </div>
<div className="table-responsive"> <div className="table-responsive">
@ -220,14 +218,14 @@ export class Communities extends Component<any, CommunitiesState> {
searchForm() { searchForm() {
return ( return (
<form <form
className="row justify-content-end" className="row mb-2"
onSubmit={linkEvent(this, this.handleSearchSubmit)} onSubmit={linkEvent(this, this.handleSearchSubmit)}
> >
<div className="col-auto"> <div className="col-auto">
<input <input
type="text" type="text"
id="communities-search" id="communities-search"
className="form-control me-2 mb-2" className="form-control"
value={this.state.searchText} value={this.state.searchText}
placeholder={`${i18n.t("search")}...`} placeholder={`${i18n.t("search")}...`}
onInput={linkEvent(this, this.handleSearchChange)} onInput={linkEvent(this, this.handleSearchChange)}
@ -239,7 +237,7 @@ export class Communities extends Component<any, CommunitiesState> {
<label className="visually-hidden" htmlFor="communities-search"> <label className="visually-hidden" htmlFor="communities-search">
{i18n.t("search")} {i18n.t("search")}
</label> </label>
<button type="submit" className="btn btn-secondary mb-2"> <button type="submit" className="btn btn-secondary">
<span>{i18n.t("search")}</span> <span>{i18n.t("search")}</span>
</button> </button>
</div> </div>

View file

@ -1,3 +1,4 @@
import classNames from "classnames";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { import {
BannedPersonsResponse, BannedPersonsResponse,
@ -130,22 +131,30 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
{ {
key: "site", key: "site",
label: i18n.t("site"), label: i18n.t("site"),
getNode: () => ( getNode: isSelected => (
<div className="row"> <div
<div className="col-12 col-md-6"> className={classNames("tab-pane show", {
<SiteForm active: isSelected,
showLocal={showLocal(this.isoData)} })}
allowedInstances={federationData?.allowed} role="tabpanel"
blockedInstances={federationData?.blocked} id="site-tab-pane"
onSaveSite={this.handleEditSite} >
siteRes={this.state.siteRes} <div className="row">
themeList={this.state.themeList} <div className="col-12 col-md-6">
loading={this.state.loading} <SiteForm
/> showLocal={showLocal(this.isoData)}
</div> allowedInstances={federationData?.allowed}
<div className="col-12 col-md-6"> blockedInstances={federationData?.blocked}
{this.admins()} onSaveSite={this.handleEditSite}
{this.bannedUsers()} siteRes={this.state.siteRes}
themeList={this.state.themeList}
loading={this.state.loading}
/>
</div>
<div className="col-12 col-md-6">
{this.admins()}
{this.bannedUsers()}
</div>
</div> </div>
</div> </div>
), ),
@ -153,40 +162,64 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
{ {
key: "rate_limiting", key: "rate_limiting",
label: "Rate Limiting", label: "Rate Limiting",
getNode: () => ( getNode: isSelected => (
<RateLimitForm <div
rateLimits={ className={classNames("tab-pane", {
this.state.siteRes.site_view.local_site_rate_limit active: isSelected,
} })}
onSaveSite={this.handleEditSite} role="tabpanel"
loading={this.state.loading} id="rate_limiting-tab-pane"
/> >
), <RateLimitForm
}, rateLimits={
{ this.state.siteRes.site_view.local_site_rate_limit
key: "taglines", }
label: i18n.t("taglines"),
getNode: () => (
<div className="row">
<TaglineForm
taglines={this.state.siteRes.taglines}
onSaveSite={this.handleEditSite} onSaveSite={this.handleEditSite}
loading={this.state.loading} loading={this.state.loading}
/> />
</div> </div>
), ),
}, },
{
key: "taglines",
label: i18n.t("taglines"),
getNode: isSelected => (
<div
className={classNames("tab-pane", {
active: isSelected,
})}
role="tabpanel"
id="taglines-tab-pane"
>
<div className="row">
<TaglineForm
taglines={this.state.siteRes.taglines}
onSaveSite={this.handleEditSite}
loading={this.state.loading}
/>
</div>
</div>
),
},
{ {
key: "emojis", key: "emojis",
label: i18n.t("emojis"), label: i18n.t("emojis"),
getNode: () => ( getNode: isSelected => (
<div className="row"> <div
<EmojiForm className={classNames("tab-pane", {
onCreate={this.handleCreateEmoji} active: isSelected,
onDelete={this.handleDeleteEmoji} })}
onEdit={this.handleEditEmoji} role="tabpanel"
loading={this.state.emojiLoading} id="emojis-tab-pane"
/> >
<div className="row">
<EmojiForm
onCreate={this.handleCreateEmoji}
onDelete={this.handleDeleteEmoji}
onEdit={this.handleEditEmoji}
loading={this.state.emojiLoading}
/>
</div>
</div> </div>
), ),
}, },

View file

@ -570,8 +570,6 @@ export class Home extends Component<any, HomeState> {
data-tippy-content={ data-tippy-content={
subscribedCollapsed ? i18n.t("expand") : i18n.t("collapse") subscribedCollapsed ? i18n.t("expand") : i18n.t("collapse")
} }
data-bs-toggle="collapse"
data-bs-target="#sidebarSubscribedBody"
aria-expanded="true" aria-expanded="true"
aria-controls="sidebarSubscribedBody" aria-controls="sidebarSubscribedBody"
> >
@ -582,24 +580,25 @@ export class Home extends Component<any, HomeState> {
</button> </button>
)} )}
</header> </header>
<div {!subscribedCollapsed && (
id="sidebarSubscribedBody" <div
className="collapse show" id="sidebarSubscribedBody"
aria-labelledby="sidebarSubscribedHeader" aria-labelledby="sidebarSubscribedHeader"
> >
<div className="card-body"> <div className="card-body">
<ul className="list-inline mb-0"> <ul className="list-inline mb-0">
{UserService.Instance.myUserInfo?.follows.map(cfv => ( {UserService.Instance.myUserInfo?.follows.map(cfv => (
<li <li
key={cfv.community.id} key={cfv.community.id}
className="list-inline-item d-inline-block" className="list-inline-item d-inline-block"
> >
<CommunityLink community={cfv.community} /> <CommunityLink community={cfv.community} />
</li> </li>
))} ))}
</ul> </ul>
</div>
</div> </div>
</div> )}
</> </>
); );
} }

View file

@ -1,3 +1,4 @@
import classNames from "classnames";
import { Component, FormEventHandler, linkEvent } from "inferno"; import { Component, FormEventHandler, linkEvent } from "inferno";
import { EditSite, LocalSiteRateLimit } from "lemmy-js-client"; import { EditSite, LocalSiteRateLimit } from "lemmy-js-client";
import { i18n } from "../../i18next"; import { i18n } from "../../i18next";
@ -19,6 +20,7 @@ interface RateLimitsProps {
handleRateLimitPerSecond: FormEventHandler<HTMLInputElement>; handleRateLimitPerSecond: FormEventHandler<HTMLInputElement>;
rateLimitValue?: number; rateLimitValue?: number;
rateLimitPerSecondValue?: number; rateLimitPerSecondValue?: number;
className?: string;
} }
interface RateLimitFormProps { interface RateLimitFormProps {
@ -49,9 +51,10 @@ function RateLimits({
handleRateLimitPerSecond, handleRateLimitPerSecond,
rateLimitPerSecondValue, rateLimitPerSecondValue,
rateLimitValue, rateLimitValue,
className,
}: RateLimitsProps) { }: RateLimitsProps) {
return ( return (
<div className="mb-3 row"> <div role="tabpanel" className={classNames("mb-3 row", className)}>
<div className="col-md-6"> <div className="col-md-6">
<label htmlFor="rate-limit">{i18n.t("rate_limit")}</label> <label htmlFor="rate-limit">{i18n.t("rate_limit")}</label>
<input <input
@ -142,8 +145,11 @@ export default class RateLimitsForm extends Component<
tabs={rateLimitTypes.map(rateLimitType => ({ tabs={rateLimitTypes.map(rateLimitType => ({
key: rateLimitType, key: rateLimitType,
label: i18n.t(`rate_limit_${rateLimitType}`), label: i18n.t(`rate_limit_${rateLimitType}`),
getNode: () => ( getNode: isSelected => (
<RateLimits <RateLimits
className={classNames("tab-pane show", {
active: isSelected,
})}
handleRateLimit={linkEvent( handleRateLimit={linkEvent(
{ rateLimitType, ctx: this }, { rateLimitType, ctx: this },
handleRateLimitChange handleRateLimitChange

View file

@ -42,13 +42,11 @@ export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> {
)} )}
</header> </header>
<div {!this.state.collapsed && (
id="sidebarInfoBody" <div id="sidebarInfoBody" aria-labelledby="sidebarInfoHeader">
className="collapse show" <div className="card-body">{this.siteInfo()}</div>
aria-labelledby="sidebarInfoHeader" </div>
> )}
<div className="card-body">{this.siteInfo()}</div>
</div>
</section> </section>
</div> </div>
); );

View file

@ -1,4 +1,5 @@
import { debounce } from "@utils/helpers"; import { debounce } from "@utils/helpers";
import classNames from "classnames";
import { NoOptionI18nKeys } from "i18next"; import { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { import {
@ -265,34 +266,50 @@ export class Settings extends Component<any, SettingsState> {
); );
} }
userSettings() { userSettings(isSelected) {
return ( return (
<div className="row"> <div
<div className="col-12 col-md-6"> className={classNames("tab-pane show", {
<div className="card border-secondary mb-3"> active: isSelected,
<div className="card-body">{this.saveUserSettingsHtmlForm()}</div> })}
role="tabpanel"
id="settings-tab-pane"
>
<div className="row">
<div className="col-12 col-md-6">
<div className="card border-secondary mb-3">
<div className="card-body">{this.saveUserSettingsHtmlForm()}</div>
</div>
</div> </div>
</div> <div className="col-12 col-md-6">
<div className="col-12 col-md-6"> <div className="card border-secondary mb-3">
<div className="card border-secondary mb-3"> <div className="card-body">{this.changePasswordHtmlForm()}</div>
<div className="card-body">{this.changePasswordHtmlForm()}</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
blockCards() { blockCards(isSelected) {
return ( return (
<div className="row"> <div
<div className="col-12 col-md-6"> className={classNames("tab-pane", {
<div className="card border-secondary mb-3"> active: isSelected,
<div className="card-body">{this.blockUserCard()}</div> })}
role="tabpanel"
id="blocks-tab-pane"
>
<div className="row">
<div className="col-12 col-md-6">
<div className="card border-secondary mb-3">
<div className="card-body">{this.blockUserCard()}</div>
</div>
</div> </div>
</div> <div className="col-12 col-md-6">
<div className="col-12 col-md-6"> <div className="card border-secondary mb-3">
<div className="card border-secondary mb-3"> <div className="card-body">{this.blockCommunityCard()}</div>
<div className="card-body">{this.blockCommunityCard()}</div> </div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -114,7 +114,7 @@ export class CreatePost extends Component<
if (res.state === "success") { if (res.state === "success") {
this.setState({ this.setState({
selectedCommunityChoice: { selectedCommunityChoice: {
label: res.data.community_view.community.name, label: res.data.community_view.community.title,
value: res.data.community_view.community.id.toString(), value: res.data.community_view.community.id.toString(),
}, },
loading: false, loading: false,
@ -178,7 +178,7 @@ export class CreatePost extends Component<
id="createPostForm" id="createPostForm"
className="col-12 col-lg-6 offset-lg-3 mb-4" className="col-12 col-lg-6 offset-lg-3 mb-4"
> >
<h5>{i18n.t("create_post")}</h5> <h1 className="h4">{i18n.t("create_post")}</h1>
<PostForm <PostForm
onCreate={this.handlePostCreate} onCreate={this.handlePostCreate}
params={locationState} params={locationState}

View file

@ -79,6 +79,143 @@ interface PostFormState {
submitted: boolean; submitted: boolean;
} }
function handlePostSubmit(i: PostForm, event: any) {
event.preventDefault();
// Coerce empty url string to undefined
if ((i.state.form.url ?? "") === "") {
i.setState(s => ((s.form.url = undefined), s));
}
i.setState({ loading: true, submitted: true });
const auth = myAuthRequired();
const pForm = i.state.form;
const pv = i.props.post_view;
if (pv) {
i.props.onEdit?.({
name: pForm.name,
url: pForm.url,
body: pForm.body,
nsfw: pForm.nsfw,
post_id: pv.post.id,
language_id: pForm.language_id,
auth,
});
} else if (pForm.name && pForm.community_id) {
i.props.onCreate?.({
name: pForm.name,
community_id: pForm.community_id,
url: pForm.url,
body: pForm.body,
nsfw: pForm.nsfw,
language_id: pForm.language_id,
honeypot: pForm.honeypot,
auth,
});
}
}
function copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
const sTitle = d.suggestedTitle;
if (sTitle) {
d.i.setState(
s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
);
d.i.setState({ suggestedPostsRes: { state: "empty" } });
setTimeout(() => {
const textarea: any = document.getElementById("post-title");
autosize.update(textarea);
}, 10);
}
}
function handlePostUrlChange(i: PostForm, event: any) {
const url = event.target.value;
i.setState(prev => ({
...prev,
form: {
...prev.form,
url,
},
imageDeleteUrl: "",
}));
i.fetchPageTitle();
}
function handlePostNsfwChange(i: PostForm, event: any) {
i.setState(s => ((s.form.nsfw = event.target.checked), s));
}
function handleHoneyPotChange(i: PostForm, event: any) {
i.setState(s => ((s.form.honeypot = event.target.value), s));
}
function handleCancel(i: PostForm) {
i.props.onCancel?.();
}
function handleImageUploadPaste(i: PostForm, event: any) {
const image = event.clipboardData.files[0];
if (image) {
handleImageUpload(i, image);
}
}
function handleImageUpload(i: PostForm, event: any) {
let file: any;
if (event.target) {
event.preventDefault();
file = event.target.files[0];
} else {
file = event;
}
i.setState({ imageLoading: true });
HttpService.client.uploadImage({ image: file }).then(res => {
console.log("pictrs upload:");
console.log(res);
if (res.state === "success") {
if (res.data.msg === "ok") {
i.state.form.url = res.data.url;
i.setState({
imageLoading: false,
imageDeleteUrl: res.data.delete_url as string,
});
} else {
toast(JSON.stringify(res), "danger");
}
} else if (res.state === "failed") {
console.error(res.msg);
toast(res.msg, "danger");
i.setState({ imageLoading: false });
}
});
}
function handlePostNameChange(i: PostForm, event: any) {
i.setState(s => ((s.form.name = event.target.value), s));
i.fetchSimilarPosts();
}
function handleImageDelete(i: PostForm) {
const { imageDeleteUrl } = i.state;
fetch(imageDeleteUrl);
i.setState(prev => ({
...prev,
imageDeleteUrl: "",
imageLoading: false,
form: {
...prev.form,
url: "",
},
}));
}
export class PostForm extends Component<PostFormProps, PostFormState> { export class PostForm extends Component<PostFormProps, PostFormState> {
state: PostFormState = { state: PostFormState = {
suggestedPostsRes: { state: "empty" }, suggestedPostsRes: { state: "empty" },
@ -123,16 +260,16 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
...this.state.form, ...this.state.form,
community_id: getIdFromString(selectedCommunityChoice.value), community_id: getIdFromString(selectedCommunityChoice.value),
}, },
communitySearchOptions: [selectedCommunityChoice] communitySearchOptions: [selectedCommunityChoice].concat(
.concat( (
this.props.initialCommunities?.map( this.props.initialCommunities?.map(
({ community: { id, title } }) => ({ ({ community: { id, title } }) => ({
label: title, label: title,
value: id.toString(), value: id.toString(),
}) })
) ?? [] ) ?? []
) ).filter(option => option.value !== selectedCommunityChoice.value)
.filter(option => option.value !== selectedCommunityChoice.value), ),
}; };
} else { } else {
this.state = { this.state = {
@ -188,15 +325,8 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
const url = this.state.form.url; const url = this.state.form.url;
// TODO
// const promptCheck =
// !!this.state.form.name || !!this.state.form.url || !!this.state.form.body;
// <Prompt when={promptCheck} message={i18n.t("block_leaving")} />
return ( return (
<form <form className="post-form" onSubmit={linkEvent(this, handlePostSubmit)}>
className="post-form"
onSubmit={linkEvent(this, this.handlePostSubmit)}
>
<NavigationPrompt <NavigationPrompt
when={ when={
!!( !!(
@ -215,9 +345,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
type="url" type="url"
id="post-url" id="post-url"
className="form-control" className="form-control"
value={this.state.form.url} value={url}
onInput={linkEvent(this, this.handlePostUrlChange)} onInput={linkEvent(this, handlePostUrlChange)}
onPaste={linkEvent(this, this.handleImageUploadPaste)} onPaste={linkEvent(this, handleImageUploadPaste)}
/> />
{this.renderSuggestedTitleCopy()} {this.renderSuggestedTitleCopy()}
<form> <form>
@ -237,7 +367,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
name="file" name="file"
className="d-none" className="d-none"
disabled={!UserService.Instance.myUserInfo} disabled={!UserService.Instance.myUserInfo}
onChange={linkEvent(this, this.handleImageUpload)} onChange={linkEvent(this, handleImageUpload)}
/> />
</form> </form>
{url && validURL(url) && ( {url && validURL(url) && (
@ -276,7 +406,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
{this.state.imageDeleteUrl && ( {this.state.imageDeleteUrl && (
<button <button
className="btn btn-danger btn-sm mt-2" className="btn btn-danger btn-sm mt-2"
onClick={linkEvent(this, this.handleImageDelete)} onClick={linkEvent(this, handleImageDelete)}
aria-label={i18n.t("delete")} aria-label={i18n.t("delete")}
data-tippy-content={i18n.t("delete")} data-tippy-content={i18n.t("delete")}
> >
@ -327,7 +457,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
<textarea <textarea
value={this.state.form.name} value={this.state.form.name}
id="post-title" id="post-title"
onInput={linkEvent(this, this.handlePostNameChange)} onInput={linkEvent(this, handlePostNameChange)}
className={`form-control ${ className={`form-control ${
!validTitle(this.state.form.name) && "is-invalid" !validTitle(this.state.form.name) && "is-invalid"
}`} }`}
@ -357,6 +487,13 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
/> />
</div> </div>
</div> </div>
<LanguageSelect
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
selectedLanguageIds={selectedLangs}
multiple={false}
onChange={this.handleLanguageChange}
/>
{!this.props.post_view && ( {!this.props.post_view && (
<div className="mb-3 row"> <div className="mb-3 row">
<label className="col-sm-2 col-form-label" htmlFor="post-community"> <label className="col-sm-2 col-form-label" htmlFor="post-community">
@ -381,30 +518,17 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
</div> </div>
)} )}
{this.props.enableNsfw && ( {this.props.enableNsfw && (
<div className="mb-3 row"> <div className="form-check mb-3">
<legend className="col-form-label col-sm-2 pt-0"> <input
{i18n.t("nsfw")} className="form-check-input"
</legend> id="post-nsfw"
<div className="col-sm-10"> type="checkbox"
<div className="form-check"> checked={this.state.form.nsfw}
<input onChange={linkEvent(this, handlePostNsfwChange)}
className="form-check-input position-static" />
id="post-nsfw" <label className="form-check-label">{i18n.t("nsfw")}</label>
type="checkbox"
checked={this.state.form.nsfw}
onChange={linkEvent(this, this.handlePostNsfwChange)}
/>
</div>
</div>
</div> </div>
)} )}
<LanguageSelect
allLanguages={this.props.allLanguages}
siteLanguages={this.props.siteLanguages}
selectedLanguageIds={selectedLangs}
multiple={false}
onChange={this.handleLanguageChange}
/>
<input <input
tabIndex={-1} tabIndex={-1}
autoComplete="false" autoComplete="false"
@ -413,7 +537,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
className="form-control honeypot" className="form-control honeypot"
id="register-honey" id="register-honey"
value={this.state.form.honeypot} value={this.state.form.honeypot}
onInput={linkEvent(this, this.handleHoneyPotChange)} onInput={linkEvent(this, handleHoneyPotChange)}
/> />
<div className="mb-3 row"> <div className="mb-3 row">
<div className="col-sm-10"> <div className="col-sm-10">
@ -434,7 +558,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
onClick={linkEvent(this, this.handleCancel)} onClick={linkEvent(this, handleCancel)}
> >
{i18n.t("cancel")} {i18n.t("cancel")}
</button> </button>
@ -459,7 +583,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
role="button" role="button"
onClick={linkEvent( onClick={linkEvent(
{ i: this, suggestedTitle }, { i: this, suggestedTitle },
this.copySuggestedTitle copySuggestedTitle
)} )}
> >
{i18n.t("copy_suggested_title", { title: "" })} {suggestedTitle} {i18n.t("copy_suggested_title", { title: "" })} {suggestedTitle}
@ -517,69 +641,6 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
} }
} }
handlePostSubmit(i: PostForm, event: any) {
event.preventDefault();
// Coerce empty url string to undefined
if ((i.state.form.url ?? "") === "") {
i.setState(s => ((s.form.url = undefined), s));
}
i.setState({ loading: true, submitted: true });
const auth = myAuthRequired();
const pForm = i.state.form;
const pv = i.props.post_view;
if (pv) {
i.props.onEdit?.({
name: pForm.name,
url: pForm.url,
body: pForm.body,
nsfw: pForm.nsfw,
post_id: pv.post.id,
language_id: pForm.language_id,
auth,
});
} else if (pForm.name && pForm.community_id) {
i.props.onCreate?.({
name: pForm.name,
community_id: pForm.community_id,
url: pForm.url,
body: pForm.body,
nsfw: pForm.nsfw,
language_id: pForm.language_id,
honeypot: pForm.honeypot,
auth,
});
}
}
copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
const sTitle = d.suggestedTitle;
if (sTitle) {
d.i.setState(
s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
);
d.i.setState({ suggestedPostsRes: { state: "empty" } });
setTimeout(() => {
const textarea: any = document.getElementById("post-title");
autosize.update(textarea);
}, 10);
}
}
handlePostUrlChange(i: PostForm, event: any) {
const url = event.target.value;
i.setState({
form: {
url,
},
imageDeleteUrl: "",
});
i.fetchPageTitle();
}
async fetchPageTitle() { async fetchPageTitle() {
const url = this.state.form.url; const url = this.state.form.url;
if (url && validURL(url)) { if (url && validURL(url)) {
@ -590,11 +651,6 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
} }
} }
handlePostNameChange(i: PostForm, event: any) {
i.setState(s => ((s.form.name = event.target.value), s));
i.fetchSimilarPosts();
}
async fetchSimilarPosts() { async fetchSimilarPosts() {
const q = this.state.form.name; const q = this.state.form.name;
if (q && q !== "") { if (q && q !== "") {
@ -618,84 +674,10 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.setState(s => ((s.form.body = val), s)); this.setState(s => ((s.form.body = val), s));
} }
handlePostCommunityChange(i: PostForm, event: any) {
i.setState(s => ((s.form.community_id = Number(event.target.value)), s));
}
handlePostNsfwChange(i: PostForm, event: any) {
i.setState(s => ((s.form.nsfw = event.target.checked), s));
}
handleLanguageChange(val: number[]) { handleLanguageChange(val: number[]) {
this.setState(s => ((s.form.language_id = val.at(0)), s)); this.setState(s => ((s.form.language_id = val.at(0)), s));
} }
handleHoneyPotChange(i: PostForm, event: any) {
i.setState(s => ((s.form.honeypot = event.target.value), s));
}
handleCancel(i: PostForm) {
i.props.onCancel?.();
}
handlePreviewToggle(i: PostForm, event: any) {
event.preventDefault();
i.setState({ previewMode: !i.state.previewMode });
}
handleImageUploadPaste(i: PostForm, event: any) {
const image = event.clipboardData.files[0];
if (image) {
i.handleImageUpload(i, image);
}
}
handleImageUpload(i: PostForm, event: any) {
let file: any;
if (event.target) {
event.preventDefault();
file = event.target.files[0];
} else {
file = event;
}
i.setState({ imageLoading: true });
HttpService.client.uploadImage({ image: file }).then(res => {
console.log("pictrs upload:");
console.log(res);
if (res.state === "success") {
if (res.data.msg === "ok") {
i.state.form.url = res.data.url;
i.setState({
imageLoading: false,
imageDeleteUrl: res.data.delete_url as string,
});
} else {
toast(JSON.stringify(res), "danger");
}
} else if (res.state === "failed") {
console.error(res.msg);
toast(res.msg, "danger");
i.setState({ imageLoading: false });
}
});
}
handleImageDelete(i: PostForm) {
const { imageDeleteUrl } = i.state;
fetch(imageDeleteUrl);
i.setState({
imageDeleteUrl: "",
imageLoading: false,
form: {
url: "",
},
});
}
handleCommunitySearch = debounce(async (text: string) => { handleCommunitySearch = debounce(async (text: string) => {
const { selectedCommunityChoice } = this.props; const { selectedCommunityChoice } = this.props;
this.setState({ communitySearchLoading: true }); this.setState({ communitySearchLoading: true });

View file

@ -734,24 +734,22 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
get commentsButton() { get commentsButton() {
const post_view = this.postView; const post_view = this.postView;
const title = i18n.t("number_of_comments", {
count: Number(post_view.counts.comments),
formattedCount: Number(post_view.counts.comments),
});
return ( return (
<Link <Link
className="btn btn-link text-muted ps-0 text-muted" className="btn btn-link btn-sm text-muted ps-0"
title={i18n.t("number_of_comments", { title={title}
count: Number(post_view.counts.comments),
formattedCount: Number(post_view.counts.comments),
})}
to={`/post/${post_view.post.id}?scrollToComments=true`} to={`/post/${post_view.post.id}?scrollToComments=true`}
data-tippy-content={title}
> >
<Icon icon="message-square" classes="me-1" inline /> <Icon icon="message-square" classes="me-1" inline />
<span className="me-2"> {post_view.counts.comments}
{i18n.t("number_of_comments", {
count: Number(post_view.counts.comments),
formattedCount: numToSI(post_view.counts.comments),
})}
</span>
{this.unreadCount && ( {this.unreadCount && (
<span className="small text-warning"> <span className="badge text-bg-warning">
({this.unreadCount} {i18n.t("new")}) ({this.unreadCount} {i18n.t("new")})
</span> </span>
)} )}

View file

@ -65,10 +65,10 @@ export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
export const repoUrl = "https://github.com/LemmyNet"; export const repoUrl = "https://github.com/LemmyNet";
export const joinLemmyUrl = "https://join-lemmy.org"; export const joinLemmyUrl = "https://join-lemmy.org";
export const donateLemmyUrl = `${joinLemmyUrl}/donate`; export const donateLemmyUrl = `${joinLemmyUrl}/donate`;
export const docsUrl = `${joinLemmyUrl}/docs/en/index.html`; export const docsUrl = `${joinLemmyUrl}/docs/index.html`;
export const helpGuideUrl = `${joinLemmyUrl}/docs/en/users/01-getting-started.html`; // TODO find a way to redirect to the non-en folder export const helpGuideUrl = `${joinLemmyUrl}/docs/users/01-getting-started.html`; // TODO find a way to redirect to the non-en folder
export const markdownHelpUrl = `${joinLemmyUrl}/docs/en/users/02-media.html`; export const markdownHelpUrl = `${joinLemmyUrl}/docs/users/02-media.html`;
export const sortingHelpUrl = `${joinLemmyUrl}/docs/en/users/03-votes-and-ranking.html`; export const sortingHelpUrl = `${joinLemmyUrl}/docs/users/03-votes-and-ranking.html`;
export const archiveTodayUrl = "https://archive.today"; export const archiveTodayUrl = "https://archive.today";
export const ghostArchiveUrl = "https://ghostarchive.org"; export const ghostArchiveUrl = "https://ghostarchive.org";
export const webArchiveUrl = "https://web.archive.org"; export const webArchiveUrl = "https://web.archive.org";

View file

@ -21,7 +21,7 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"paths": { "paths": {
"@/*": ["/*"], "@/*": ["/*"],
"@utils/*": ["shared/utils/*"], "@utils/*": ["shared/utils/*"]
} }
}, },
"include": [ "include": [

View file

@ -5695,10 +5695,10 @@ leac@^0.6.0:
resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912" resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg== integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
lemmy-js-client@0.18.0-rc.1: lemmy-js-client@0.18.0-rc.2:
version "0.18.0-rc.1" version "0.18.0-rc.2"
resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.18.0-rc.1.tgz#fd0c88810572d90413696011ebaed19e3b8162d8" resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.18.0-rc.2.tgz#6cd8b4dc95de8f2a6f99de56819c141a394dca04"
integrity sha512-lQe443Nr5UCSoY+IxmT7mBe0IRF6EAZ/4PJSRoPSL+U8A+egMMBPbuxnisHzLsC+eDOWRUIgOqZlwlaRnbmuig== integrity sha512-bnYs89MjlQHwVIr1YIoAvgFkCTWrXDjSgPbCJx8ijrxZXqOKW/KAgWEisfqyFpy3dYpA3/sxFjh7b4sdxM+8VA==
dependencies: dependencies:
cross-fetch "^3.1.5" cross-fetch "^3.1.5"
form-data "^4.0.0" form-data "^4.0.0"