Skip to content

Commit

Permalink
Adds a text view similar to the previous version
Browse files Browse the repository at this point in the history
  • Loading branch information
arcanis committed Nov 29, 2024
1 parent 2e9d8c3 commit fc33dd4
Show file tree
Hide file tree
Showing 9 changed files with 347 additions and 131 deletions.
14 changes: 8 additions & 6 deletions src/components/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import { CaretRight } from '@phosphor-icons/react';
import { useRef, useEffect, useState } from 'react';

interface AccordionProps {
title: string;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
titleClassName?: string;
action?: React.ReactNode;
}

export function Accordion({ title, isOpen, onToggle, children }: AccordionProps) {
export function Accordion({ title, isOpen, onToggle, children, action }: AccordionProps) {
return <>
<button
onClick={onToggle}
className="w-full p-1 text-left mb-2 bg-postcard-nowhite rounded-lg"
>
<div className="flex px-4 py-2 items-center justify-between text-white rounded">
<h2 className={`text-xl`}>{title}</h2>
<span className={`transition-transform duration-300 ${isOpen ? 'rotate-90' : 'rotate-180'}`}>
<CaretRight className={`w-4 h-4`} weight={`fill`}/>
</span>
<div className="flex items-center gap-2">
{action}
<span className={`transition-transform duration-300 ${isOpen ? 'rotate-90' : 'rotate-180'}`}>
<CaretRight className={`w-4 h-4`} weight={`fill`}/>
</span>
</div>
</div>
</button>

Expand Down
32 changes: 18 additions & 14 deletions src/components/ParticipantsList.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState } from 'react';
import { ArrowsClockwise } from "@phosphor-icons/react";
import { ArrowsClockwise, List, TextT } from "@phosphor-icons/react";
import { Participant } from '../types';
import { useTranslation } from 'react-i18next';
import { ParticipantRow } from './ParticipantRow';
import { ParticipantsTextView } from './ParticipantsTextView';
import { produce } from 'immer';

interface ParticipantsListProps {
Expand All @@ -20,6 +21,7 @@ export function ParticipantsList({
}: ParticipantsListProps) {
const { t } = useTranslation();
const [nextParticipantId, setNextParticipantId] = useState(() => crypto.randomUUID());
const [isTextView, setIsTextView] = useState(false);

const updateParticipant = (id: string, name: string) => {
if (id === nextParticipantId) {
Expand Down Expand Up @@ -51,23 +53,25 @@ export function ParticipantsList({
}];

return (
<div className="space-y-2 pr-2">
{participantsList.map((participant, index) => (
<ParticipantRow
key={participant.id}
participant={participant}
participantIndex={index}
isLast={index === Object.keys(participants).length}
onNameChange={(name) => updateParticipant(participant.id, name)}
onOpenRules={() => onOpenRules(participant.id)}
onRemove={() => removeParticipant(participant.id)}
/>
))}
<div className="space-y-2">
<div className="space-y-2 pr-2">
{participantsList.map((participant, index) => (
<ParticipantRow
key={participant.id}
participant={participant}
participantIndex={index}
isLast={index === Object.keys(participants).length}
onNameChange={(name) => updateParticipant(participant.id, name)}
onOpenRules={() => onOpenRules(participant.id)}
onRemove={() => removeParticipant(participant.id)}
/>
))}
</div>

<button
type="button"
onClick={onGeneratePairs}
className="w-full bg-green-500 text-white p-2 rounded hover:bg-green-600 flex items-center justify-center gap-2"
className="w-full bg-green-500 text-white p-2 rounded hover:bg-blue-600 flex items-center justify-center gap-2"
>
<ArrowsClockwise size={20} weight="bold" />
{t('participants.generatePairs')}
Expand Down
57 changes: 57 additions & 0 deletions src/components/ParticipantsTextView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Participant } from '../types';
import { useState, useEffect } from 'react';
import { parseParticipantsText, ParseError, formatParticipantText } from '../utils/parseParticipants';
import { ArrowsClockwise } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';

interface ParticipantsTextViewProps {
participants: Record<string, Participant>;
onChangeParticipants: (newParticipants: Record<string, Participant>) => void;
onGeneratePairs: () => void;
}

export function ParticipantsTextView({ participants, onChangeParticipants, onGeneratePairs }: ParticipantsTextViewProps) {
const { t } = useTranslation();

const [text, setText] = useState(() => formatParticipantText(participants));
const [error, setError] = useState<ParseError | null>(null);

const handleChange = (newText: string) => {
setText(newText);

const result = parseParticipantsText(newText);
if (result.ok) {
setError(null);
onChangeParticipants(result.participants);
} else {
setError(result);
}
};

return (
<div className="relative space-y-2">
<textarea
className={`block w-full h-48 p-2 font-mono text-sm border rounded text-nowrap ${
error ? 'border-red-500' : ''
}`}
value={text}
onChange={e => handleChange(e.target.value)}
/>

{error && (
<div className="bg-red-100 text-red-700 text-sm p-2 rounded">
{t('errors.line', { number: error.line })}: {t(error.key as any, error.values)}
</div>
)}

<button
type="button"
onClick={onGeneratePairs}
className="w-full bg-green-500 text-white p-2 rounded hover:bg-blue-600 flex items-center justify-center gap-2"
>
<ArrowsClockwise size={20} weight="bold" />
{t('participants.generatePairs')}
</button>
</div>
);
}
20 changes: 15 additions & 5 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ export const en = {
flag: "🇺🇸",
name: "English",
},
errors: {
needMoreParticipants: "Need at least 2 participants!",
invalidPairs: "Couldn't generate valid pairs with the current rules. Please check the rules and try again.",
multipleMustRules: "Multiple MUST rules found",
conflictingRules: "Conflicting use of a MUST and MUST NOT rule",
emptyName: "Empty name",
duplicateName: "Duplicate name: {{name}}",
invalidRuleFormat: "Invalid rule format: {{rule}}",
unknownParticipant: "Unknown participant in rule: {{name}}",
noValidReceivers: "No valid receivers left for this participant",
line: "Line {{number}}"
},
home: {
vanity: "Project started in winter 2015 by Maël",
title: "Secret Santa Planner",
Expand All @@ -12,10 +24,6 @@ export const en = {
"No accounts, no emails, no hassle, and all hosted on <githubLink>GitHub Pages</githubLink> with no backend!",
].map(line => `<p>${line}</p>`).join(''),
exampleLink: "Example link",
errors: {
needMoreParticipants: "Need at least 2 participants!",
invalidPairs: "Couldn't generate valid pairs with the current rules. Please check the rules and try again."
}
},
pairing: {
title: "Your Secret Santa Assignment",
Expand All @@ -32,7 +40,9 @@ export const en = {
editRules: "Edit rules",
removeParticipant: "Remove participant",
rulesCount_one: "{{count}} rule set",
rulesCount_other: "{{count}} rules set"
rulesCount_other: "{{count}} rules set",
switchToFormView: "Switch to form view",
switchToTextView: "Switch to text view"
},
rules: {
title: "Rules for {{name}}",
Expand Down
20 changes: 15 additions & 5 deletions src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ export const fr: Translations = {
flag: "🇫🇷",
name: "Français",
},
errors: {
needMoreParticipants: "Il faut au moins 2 participants !",
invalidPairs: "Impossible de générer des paires valides avec les règles actuelles. Veuillez vérifier les règles et réessayer.",
multipleMustRules: "Conflit entre plusieurs règles requérant une association",
conflictingRules: "Conflit entre une règle requérant une association et une règle excluant cette même association",
emptyName: "Nom vide",
duplicateName: "Nom en double : {{name}}",
invalidRuleFormat: "Format de règle invalide : {{rule}}",
unknownParticipant: "Participant inconnu dans la règle : {{name}}",
noValidReceivers: "Aucun receveur valide restant pour ce participant",
line: "Ligne {{number}}"
},
home: {
vanity: "Projet lancé en hiver 2015 par Maël",
title: "Planificateur de Secret Santa",
Expand All @@ -14,10 +26,6 @@ export const fr: Translations = {
"Pas de comptes, pas d'emails, pas de tracas, et le tout hébergé sur de simples <githubLink>GitHub Pages</githubLink> !",
].map(line => `<p>${line}</p>`).join(''),
exampleLink: "Exemple de lien",
errors: {
needMoreParticipants: "Il faut au moins 2 participants !",
invalidPairs: "Impossible de générer des paires valides avec les règles actuelles. Veuillez vérifier les règles et réessayer."
}
},
pairing: {
title: "Votre Partenaire de Secret Santa",
Expand All @@ -34,7 +42,9 @@ export const fr: Translations = {
editRules: "Modifier les règles",
removeParticipant: "Supprimer le participant",
rulesCount_one: "{{count}} règle définie",
rulesCount_other: "{{count}} règles définies"
rulesCount_other: "{{count}} règles définies",
switchToFormView: "Passer à la vue formulaire",
switchToTextView: "Passer à la vue texte",
},
rules: {
title: "Règles pour {{name}}",
Expand Down
50 changes: 37 additions & 13 deletions src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { GeneratedPairs, generatePairs } from '../utils/generatePairs';
import { Accordion } from '../components/Accordion';
import { AccordionContainer } from '../components/AccordionContainer';
import { ParticipantsList } from '../components/ParticipantsList';
import { ParticipantsTextView } from '../components/ParticipantsTextView';
import { SecretSantaLinks } from '../components/SecretSantaLinks';
import { Participant, Rule } from '../types';
import { Link } from 'react-router-dom';
import { PostCard } from '../components/PostCard';
import { Trans, useTranslation } from 'react-i18next';
import { MenuItem, SideMenu } from '../components/SideMenu';
import { MenuItem } from '../components/SideMenu';
import { PageTransition } from '../components/PageTransition';
import { Heart } from '@phosphor-icons/react';
import { Code, Heart, Rows } from '@phosphor-icons/react';
import { Settings } from '../components/Settings';
import { useLocalStorage } from '../hooks/useLocalStorage';
import { Layout } from '../components/Layout';
Expand Down Expand Up @@ -78,6 +79,7 @@ function migrateAssignments(value: any) {

export function Home() {
const { t } = useTranslation();
const [isTextView, setIsTextView] = useState(false);

const [participants, setParticipants] = useLocalStorage<Record<string, Participant>>('secretSantaParticipants', {}, migrateParticipants);
const [assignments, setAssignments] = useLocalStorage<GeneratedPairs | null>('secretSantaAssignments', null, migrateAssignments);
Expand All @@ -91,8 +93,8 @@ export function Home() {
const assignments = generatePairs(participants);
if (assignments === null) {
alert(Object.keys(participants).length < 2
? t('home.errors.needMoreParticipants')
: t('home.errors.invalidPairs')
? t('errors.needMoreParticipants')
: t('errors.invalidPairs')
);
return;
}
Expand All @@ -107,6 +109,19 @@ export function Home() {
</MenuItem>
];

const toggleViewButton = (
<button
onClick={(e) => {
e.stopPropagation();
setIsTextView(!isTextView);
}}
className="p-2 text-gray-200 hover:bg-gray-700 rounded-full"
title={t(isTextView ? 'participants.switchToFormView' : 'participants.switchToTextView')}
>
{isTextView ? <Rows size={20} weight={`bold`} /> : <Code size={20} weight={`bold`} />}
</button>
);

return <>
<PageTransition>
<Layout menuItems={menuItems}>
Expand Down Expand Up @@ -136,16 +151,25 @@ export function Home() {
title={t('participants.title')}
isOpen={openSection === 'participants'}
onToggle={() => setOpenSection('participants')}
action={toggleViewButton}
>
<ParticipantsList
participants={participants}
onChangeParticipants={setParticipants}
onOpenRules={(id) => {
setSelectedParticipantId(id);
setIsRulesModalOpen(true);
}}
onGeneratePairs={handleGeneratePairs}
/>
{isTextView ? (
<ParticipantsTextView
participants={participants}
onChangeParticipants={setParticipants}
onGeneratePairs={handleGeneratePairs}
/>
) : (
<ParticipantsList
participants={participants}
onChangeParticipants={setParticipants}
onOpenRules={(id) => {
setSelectedParticipantId(id);
setIsRulesModalOpen(true);
}}
onGeneratePairs={handleGeneratePairs}
/>
)}
</Accordion>

<Accordion
Expand Down
4 changes: 2 additions & 2 deletions src/utils/generatePairs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe('generatePairs', () => {
rules: fc.array(
fc.record({
type: fc.constantFrom<'must' | 'mustNot'>('must', 'mustNot'),
targetParticipantId: fc.string()
targetParticipantId: fc.integer({ min: 0 })
}),
{ maxLength: 3 }
)
Expand All @@ -38,7 +38,7 @@ describe('generatePairs', () => {
.map(r => ({
...r,
targetParticipantId: Object.keys(participants)[
parseInt(r.targetParticipantId) % Object.keys(participants).length
r.targetParticipantId % Object.keys(participants).length
]
}));

Expand Down
Loading

0 comments on commit fc33dd4

Please sign in to comment.