GameListItem.jsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. import React from "react";
  2. import styled from "styled-components";
  3. import { Link, useHistory } from "react-router-dom";
  4. import { useTranslation } from "react-i18next";
  5. import { deleteGame, getBestTranslationFromConfig } from "../utils/api";
  6. import { confirmAlert } from "react-confirm-alert";
  7. import { toast } from "react-toastify";
  8. import { useMutation, useQueryClient } from "react-query";
  9. import { media2Url } from "../mediaLibrary";
  10. const Game = styled.li`
  11. position: relative;
  12. padding: 0em;
  13. margin: 0px;
  14. min-width: 0; /* Fix for ellipsis */
  15. & .game-name {
  16. max-width: 80%;
  17. line-height: 1.1em;
  18. overflow: hidden;
  19. margin-bottom: 3px;
  20. margin: 0.1em 0 0.1em 0;
  21. font-size: 2.2vw;
  22. white-space: nowrap;
  23. overflow: hidden;
  24. text-overflow: ellipsis;
  25. }
  26. & .unpublished {
  27. position: absolute;
  28. top: 0.5em;
  29. left: 0.5em;
  30. padding: 0em;
  31. }
  32. & .button.play {
  33. margin: 0 2px;
  34. background-color: var(--color-secondary);
  35. }
  36. & .play {
  37. position: absolute;
  38. bottom: 0.5em;
  39. right: 0.5em;
  40. }
  41. & .extra-actions {
  42. position: absolute;
  43. top: 0.5em;
  44. right: 0.5em;
  45. display: none;
  46. z-index: 2;
  47. & .button {
  48. border-radius: 4px;
  49. }
  50. }
  51. & .baseline {
  52. max-height: 3em;
  53. overflow: hidden;
  54. }
  55. &:hover .extra-actions,
  56. & .extra-actions:hover {
  57. display: block;
  58. }
  59. & .img-wrapper {
  60. display: block;
  61. position: relative;
  62. margin: 0;
  63. padding: 0;
  64. width: 100%;
  65. padding-top: 64.5%;
  66. & > span {
  67. background-color: var(--color-blueGrey);
  68. ${({ other }) => (!other ? "" : "border: 1px solid red")};
  69. position: absolute;
  70. inset: 0;
  71. overflow: hidden;
  72. display: block;
  73. display: flex;
  74. justify-content: center;
  75. align-items: center;
  76. border-radius: 5px;
  77. & > .back {
  78. filter: blur(5px);
  79. background-size: cover;
  80. position: absolute;
  81. inset: 0;
  82. }
  83. & > h2 {
  84. position: absolute;
  85. width: 100%;
  86. top: calc(50%-0.6em);
  87. z-index: 200;
  88. left: 0;
  89. text-align: center;
  90. display: inline;
  91. font-size: 2em;
  92. white-space: nowrap;
  93. overflow: hidden;
  94. text-overflow: ellipsis;
  95. margin: 0;
  96. padding: 0.2em 0.5em;
  97. line-height: 1.2em;
  98. background-color: #111111a0;
  99. }
  100. }
  101. }
  102. & .img {
  103. object-fit: contain;
  104. width: 100%;
  105. height: 100%;
  106. z-index: 1;
  107. }
  108. & .details {
  109. display: flex;
  110. flex-direction: row;
  111. color: var(--font-color2);
  112. font-size: 14px;
  113. padding-top: 1em;
  114. }
  115. & .details > span {
  116. display: flex;
  117. align-items: center;
  118. padding-right: 5px;
  119. margin-right: 5px;
  120. border-right: 1px solid var(--font-color2);
  121. }
  122. & .details > span:last-child {
  123. border: none;
  124. }
  125. & .details img {
  126. margin-right: 0.5em;
  127. }
  128. @media screen and (max-width: 1024px) {
  129. & {
  130. flex-basis: 45%;
  131. }
  132. & .details {
  133. font-size: 12px;
  134. }
  135. & .game-name {
  136. font-size: 28px;
  137. }
  138. }
  139. @media screen and (max-width: 640px) {
  140. & {
  141. flex-basis: 100%;
  142. }
  143. & .game-name {
  144. font-size: 24px;
  145. }
  146. }
  147. `;
  148. const getGameUrl = (id) => `${window.location.origin}/playgame/${id}`;
  149. const GameListItem = ({
  150. game: {
  151. owner,
  152. id,
  153. board: {
  154. minAge,
  155. materialLanguage,
  156. duration,
  157. playerCount,
  158. published,
  159. imageUrl,
  160. keepTitle,
  161. },
  162. },
  163. game,
  164. userId,
  165. onClick: propOnClick,
  166. onDelete,
  167. isAdmin = false,
  168. studio = false,
  169. }) => {
  170. const { t, i18n } = useTranslation();
  171. const history = useHistory();
  172. const queryClient = useQueryClient();
  173. const deleteMutation = useMutation((gameId) => deleteGame(gameId), {
  174. onSuccess: () => {
  175. queryClient.invalidateQueries("ownGames");
  176. queryClient.invalidateQueries("games");
  177. },
  178. });
  179. const realImageUrl = media2Url(imageUrl);
  180. const [showImage, setShowImage] = React.useState(Boolean(realImageUrl));
  181. const translation = React.useMemo(
  182. () => getBestTranslationFromConfig(game.board, i18n.languages),
  183. [game, i18n.languages]
  184. );
  185. const onClick = React.useCallback(
  186. (e) => {
  187. e.stopPropagation();
  188. e.preventDefault();
  189. if (propOnClick) {
  190. return propOnClick(id);
  191. } else {
  192. history.push(`/playgame/${id}`);
  193. }
  194. },
  195. [history, id, propOnClick]
  196. );
  197. const onShare = React.useCallback(
  198. async (e) => {
  199. e.stopPropagation();
  200. e.preventDefault();
  201. await navigator.clipboard.writeText(getGameUrl(id));
  202. toast.info(t("Url copied to clipboard!"), { autoClose: 1000 });
  203. },
  204. [id, t]
  205. );
  206. const deleteGameHandler = async () => {
  207. confirmAlert({
  208. title: t("Confirmation"),
  209. message: t("Do you really want to remove this game?"),
  210. buttons: [
  211. {
  212. label: t("Yes"),
  213. onClick: async () => {
  214. try {
  215. deleteMutation.mutate(id);
  216. if (onDelete) onDelete(id);
  217. toast.success(t("Game deleted"), { autoClose: 1500 });
  218. } catch (e) {
  219. if (e.message === "Forbidden") {
  220. toast.error(t("Action forbidden. Try logging in again."));
  221. } else {
  222. console.log(e);
  223. toast.error(t("Error while deleting game. Try again later..."));
  224. }
  225. }
  226. },
  227. },
  228. {
  229. label: t("No"),
  230. onClick: () => {},
  231. },
  232. ],
  233. });
  234. };
  235. let playerCountDisplay = undefined;
  236. if (playerCount && playerCount.length) {
  237. const [min, max] = playerCount;
  238. if (min === max) {
  239. if (max === 9) {
  240. playerCountDisplay = ["9+"];
  241. } else {
  242. playerCountDisplay = [max];
  243. }
  244. } else {
  245. if (max === 9) {
  246. playerCountDisplay = [min, "9+"];
  247. } else {
  248. playerCountDisplay = [min, max];
  249. }
  250. }
  251. }
  252. let durationDisplay = undefined;
  253. if (duration && duration.length) {
  254. const [min, max] = duration;
  255. if (min === max) {
  256. if (max === 90) {
  257. durationDisplay = "90+";
  258. } else {
  259. durationDisplay = `~${max}`;
  260. }
  261. } else {
  262. if (max === 90) {
  263. durationDisplay = `${min}~90+`;
  264. } else {
  265. durationDisplay = `${min}~${max}`;
  266. }
  267. }
  268. }
  269. let materialLanguageDisplay = t(materialLanguage);
  270. const owned = userId && (userId === owner || !owner);
  271. return (
  272. <Game other={!owned && studio}>
  273. <a href={`/playgame/${id}`} className="img-wrapper button">
  274. <span onClick={onClick}>
  275. {showImage && (
  276. <>
  277. <span
  278. className="back"
  279. style={{ backgroundImage: `url(${realImageUrl})` }}
  280. />
  281. <img
  282. className="img"
  283. src={realImageUrl}
  284. alt={translation.name}
  285. onError={() => setShowImage(false)}
  286. />
  287. </>
  288. )}
  289. {(!showImage || keepTitle) && <h2>{translation.name}</h2>}
  290. </span>
  291. </a>
  292. <span className="extra-actions">
  293. <a
  294. href={getGameUrl(id)}
  295. className="button edit icon-only success"
  296. onClick={onShare}
  297. >
  298. <img
  299. src="https://icongr.am/feather/share-2.svg?size=16&color=ffffff"
  300. alt={t("Share game link")}
  301. title={t("Share game link")}
  302. />
  303. </a>
  304. {(owned || isAdmin) && (
  305. <>
  306. <button
  307. onClick={deleteGameHandler}
  308. className="button edit icon-only error"
  309. >
  310. <img
  311. src="https://icongr.am/feather/trash.svg?size=16&color=ffffff"
  312. alt={t("Delete")}
  313. title={t("Delete")}
  314. />
  315. </button>
  316. <Link to={`/game/${id}/edit`} className="button edit icon-only ">
  317. <img
  318. src="https://icongr.am/feather/edit.svg?size=16&color=ffffff"
  319. alt={t("Edit")}
  320. title={t("Edit")}
  321. />
  322. </Link>
  323. </>
  324. )}
  325. </span>
  326. {!published && (
  327. <img
  328. className="unpublished"
  329. src="https://icongr.am/entypo/eye-with-line.svg?size=32&color=888886"
  330. alt={t("Unpublished")}
  331. />
  332. )}
  333. <div className="details">
  334. {playerCountDisplay && (
  335. <span>
  336. {playerCountDisplay.length === 2 &&
  337. t("{{min}} - {{max}} players", {
  338. min: playerCountDisplay[0],
  339. max: playerCountDisplay[1],
  340. })}
  341. {playerCountDisplay.length === 1 &&
  342. t("{{count}} player", {
  343. count: playerCountDisplay[0],
  344. })}
  345. </span>
  346. )}
  347. {durationDisplay && <span>{durationDisplay} mins</span>}
  348. {minAge && <span>age {minAge}+</span>}
  349. {materialLanguageDisplay && <span>{materialLanguageDisplay}</span>}
  350. </div>
  351. <h2 className="game-name">{translation.name}</h2>
  352. <p className="baseline">{translation.baseline}</p>
  353. </Game>
  354. );
  355. };
  356. export default GameListItem;