mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2026-03-13 18:21:57 +08:00
Compare commits
10 Commits
feat/perso
...
fix/activi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0264b88069 | ||
|
|
71d84faf73 | ||
|
|
66e4793032 | ||
|
|
32cbfd6699 | ||
|
|
043bf59c87 | ||
|
|
3e0d8a5b3d | ||
|
|
2b20996134 | ||
|
|
20be28653f | ||
|
|
755e494224 | ||
|
|
f6d4da48f9 |
@@ -3,7 +3,8 @@ import { getRelativeTimeString } from '../../lib/date';
|
||||
import type { ResourceType } from '../../lib/resource-progress';
|
||||
import { EmptyStream } from './EmptyStream';
|
||||
import { ActivityTopicsModal } from './ActivityTopicsModal.tsx';
|
||||
import {Book, BookOpen, ChevronsDown, ChevronsDownUp, ChevronsUp, ChevronsUpDown} from 'lucide-react';
|
||||
import { ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||
import { ActivityTopicTitles } from './ActivityTopicTitles.tsx';
|
||||
|
||||
export const allowedActivityActionType = [
|
||||
'in_progress',
|
||||
@@ -21,7 +22,7 @@ export type UserStreamActivity = {
|
||||
resourceSlug?: string;
|
||||
isCustomResource?: boolean;
|
||||
actionType: AllowedActivityActionType;
|
||||
topicIds?: string[];
|
||||
topicTitles?: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
@@ -38,7 +39,9 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
useState<UserStreamActivity | null>(null);
|
||||
|
||||
const sortedActivities = activities
|
||||
.filter((activity) => activity?.topicIds && activity.topicIds.length > 0)
|
||||
.filter(
|
||||
(activity) => activity?.topicTitles && activity.topicTitles.length > 0,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
})
|
||||
@@ -57,8 +60,8 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
resourceId={selectedActivity.resourceId}
|
||||
resourceType={selectedActivity.resourceType}
|
||||
isCustomResource={selectedActivity.isCustomResource}
|
||||
topicIds={selectedActivity.topicIds || []}
|
||||
topicCount={selectedActivity.topicIds?.length || 0}
|
||||
topicTitles={selectedActivity.topicTitles || []}
|
||||
topicCount={selectedActivity.topicTitles?.length || 0}
|
||||
actionType={selectedActivity.actionType}
|
||||
/>
|
||||
)}
|
||||
@@ -73,7 +76,7 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
resourceTitle,
|
||||
actionType,
|
||||
updatedAt,
|
||||
topicIds,
|
||||
topicTitles,
|
||||
isCustomResource,
|
||||
} = activity;
|
||||
|
||||
@@ -96,7 +99,7 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
</a>
|
||||
);
|
||||
|
||||
const topicCount = topicIds?.length || 0;
|
||||
const topicCount = topicTitles?.length || 0;
|
||||
|
||||
const timeAgo = (
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
@@ -109,24 +112,20 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
{actionType === 'in_progress' && (
|
||||
<>
|
||||
Started{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => setSelectedActivity(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
<ActivityTopicTitles
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => setSelectedActivity(activity)}
|
||||
/>{' '}
|
||||
in {resourceLinkComponent} {timeAgo}
|
||||
</>
|
||||
)}
|
||||
{actionType === 'done' && (
|
||||
<>
|
||||
Completed{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => setSelectedActivity(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
<ActivityTopicTitles
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => setSelectedActivity(activity)}
|
||||
/>{' '}
|
||||
in {resourceLinkComponent} {timeAgo}
|
||||
</>
|
||||
)}
|
||||
@@ -146,16 +145,20 @@ export function ActivityStream(props: ActivityStreamProps) {
|
||||
|
||||
{activities.length > 10 && (
|
||||
<button
|
||||
className="mt-3 gap-2 flex items-center rounded-md border border-black pl-1.5 pr-2 py-1 text-xs uppercase tracking-wide text-black transition-colors hover:border-black hover:bg-black hover:text-white"
|
||||
className="mt-3 flex items-center gap-2 rounded-md border border-black py-1 pl-1.5 pr-2 text-xs uppercase tracking-wide text-black transition-colors hover:border-black hover:bg-black hover:text-white"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
>
|
||||
{showAll ? <>
|
||||
<ChevronsUp size={14} />
|
||||
Show less
|
||||
</> : <>
|
||||
<ChevronsDown size={14} />
|
||||
Show more
|
||||
</>}
|
||||
{showAll ? (
|
||||
<>
|
||||
<ChevronsUp size={14} />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronsDown size={14} />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
41
src/components/Activity/ActivityTopicTitles.tsx
Normal file
41
src/components/Activity/ActivityTopicTitles.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
type ActivityTopicTitlesProps = {
|
||||
topicTitles: string[];
|
||||
onSelectActivity?: () => void;
|
||||
};
|
||||
|
||||
export function ActivityTopicTitles(props: ActivityTopicTitlesProps) {
|
||||
const { topicTitles, onSelectActivity } = props;
|
||||
const firstThreeTopics = topicTitles?.slice(0, 3);
|
||||
const remainingTopics = topicTitles?.slice(3);
|
||||
|
||||
return (
|
||||
<>
|
||||
{firstThreeTopics.map((topicTitle, index) => {
|
||||
return (
|
||||
<span className="font-medium">
|
||||
<>
|
||||
{index > 0 && ', '}
|
||||
{index === firstThreeTopics.length - 1 &&
|
||||
firstThreeTopics.length > 1 &&
|
||||
'and '}
|
||||
{topicTitle}
|
||||
</>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
{remainingTopics?.length > 0 && (
|
||||
<>
|
||||
and
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={onSelectActivity}
|
||||
>
|
||||
{remainingTopics.length} more topic
|
||||
{remainingTopics.length > 1 ? 's' : ''}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type ActivityTopicDetailsProps = {
|
||||
resourceId: string;
|
||||
resourceType: ResourceType | 'question';
|
||||
isCustomResource?: boolean;
|
||||
topicIds: string[];
|
||||
topicTitles: string[];
|
||||
topicCount: number;
|
||||
actionType: AllowedActivityActionType;
|
||||
onClose: () => void;
|
||||
@@ -22,56 +22,12 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
topicIds = [],
|
||||
topicTitles = [],
|
||||
topicCount,
|
||||
actionType,
|
||||
onClose,
|
||||
} = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTopicTitles = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`,
|
||||
{
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
topicIds,
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Failed to load topic titles');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTopicTitles(response);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTopicTitles().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading || error) {
|
||||
return (
|
||||
<ModalLoader
|
||||
error={error!}
|
||||
text={'Loading topics..'}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let pageUrl = '';
|
||||
if (resourceType === 'roadmap') {
|
||||
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`;
|
||||
@@ -85,8 +41,6 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
|
||||
<Modal
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}}
|
||||
>
|
||||
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}>
|
||||
@@ -108,9 +62,7 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
|
||||
</a>
|
||||
</span>
|
||||
<ul className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto max-md:max-h-full">
|
||||
{topicIds.map((topicId) => {
|
||||
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
|
||||
|
||||
{topicTitles.map((topicTitle) => {
|
||||
const ActivityIcon =
|
||||
actionType === 'done'
|
||||
? Check
|
||||
@@ -119,7 +71,7 @@ export function ActivityTopicsModal(props: ActivityTopicDetailsProps) {
|
||||
: Check;
|
||||
|
||||
return (
|
||||
<li key={topicId} className="flex items-start gap-2">
|
||||
<li key={topicTitle} className="flex items-start gap-2">
|
||||
<ActivityIcon
|
||||
strokeWidth={3}
|
||||
className="relative top-[4px] text-green-500"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { pageProgressMessage } from '../../stores/page';
|
||||
import type { TeamResourceConfig } from './RoadmapSelector';
|
||||
import { Step3 } from './Step3';
|
||||
import { Step4 } from './Step4';
|
||||
import {useToast} from "../../hooks/use-toast";
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
|
||||
export interface TeamDocument {
|
||||
_id?: string;
|
||||
@@ -22,6 +22,7 @@ export interface TeamDocument {
|
||||
linkedIn?: string;
|
||||
};
|
||||
type: ValidTeamType;
|
||||
personalProgressOnly?: boolean;
|
||||
canMemberSendInvite: boolean;
|
||||
teamSize?: ValidTeamSize;
|
||||
createdAt: Date;
|
||||
@@ -40,10 +41,10 @@ export function CreateTeamForm() {
|
||||
|
||||
async function loadTeam(
|
||||
teamIdToFetch: string,
|
||||
requiredStepIndex: number | string
|
||||
requiredStepIndex: number | string,
|
||||
) {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamIdToFetch}`,
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
@@ -70,7 +71,7 @@ export function CreateTeamForm() {
|
||||
|
||||
async function loadTeamResourceConfig(teamId: string) {
|
||||
const { error, response } = await httpGet<TeamResourceConfig>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team-resource-config/${teamId}`,
|
||||
);
|
||||
if (error || !Array.isArray(response)) {
|
||||
console.error(error);
|
||||
@@ -96,7 +97,7 @@ export function CreateTeamForm() {
|
||||
}, [teamId, queryStepIndex]);
|
||||
|
||||
const [selectedTeamType, setSelectedTeamType] = useState<ValidTeamType>(
|
||||
team?.type || 'company'
|
||||
team?.type || 'company',
|
||||
);
|
||||
|
||||
const [completedSteps, setCompletedSteps] = useState([0]);
|
||||
@@ -191,13 +192,17 @@ export function CreateTeamForm() {
|
||||
|
||||
return (
|
||||
<div className={'mx-auto max-w-[700px] py-1 md:py-6'}>
|
||||
<div className={'mb-3 md:mb-8 pb-3 md:pb-0 border-b md:border-b-0 flex flex-col items-start md:items-center'}>
|
||||
<h1 className={'text-xl md:text-4xl font-bold'}>Create Team</h1>
|
||||
<p className={'mt-1 md:mt-2 text-sm md:text-base text-gray-500'}>
|
||||
<div
|
||||
className={
|
||||
'mb-3 flex flex-col items-start border-b pb-3 md:mb-8 md:items-center md:border-b-0 md:pb-0'
|
||||
}
|
||||
>
|
||||
<h1 className={'text-xl font-bold md:text-4xl'}>Create Team</h1>
|
||||
<p className={'mt-1 text-sm text-gray-500 md:mt-2 md:text-base'}>
|
||||
Complete the steps below to create your team
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-8 mt-8 hidden sm:flex w-full">
|
||||
<div className="mb-8 mt-8 hidden w-full sm:flex">
|
||||
<Stepper
|
||||
activeIndex={stepIndex}
|
||||
completeSteps={completedSteps}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function Step1(props: Step1Props) {
|
||||
const [linkedInUrl, setLinkedInUrl] = useState(team?.links?.linkedIn || '');
|
||||
const [gitHubUrl, setGitHubUrl] = useState(team?.links?.github || '');
|
||||
const [teamSize, setTeamSize] = useState<ValidTeamSize>(
|
||||
team?.teamSize || ('' as any)
|
||||
team?.teamSize || ('' as any),
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
@@ -74,7 +74,7 @@ export function Step1(props: Step1Props) {
|
||||
}),
|
||||
roadmapIds: [],
|
||||
bestPracticeIds: [],
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
if (error || !response?._id) {
|
||||
@@ -96,7 +96,7 @@ export function Step1(props: Step1Props) {
|
||||
teamSize,
|
||||
linkedInUrl: linkedInUrl || undefined,
|
||||
}),
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
if (error || (response as any)?.status !== 'ok') {
|
||||
@@ -168,7 +168,10 @@ export function Step1(props: Step1Props) {
|
||||
|
||||
{selectedTeamType === 'company' && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label htmlFor="website" className="text-sm leading-none text-slate-500">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
Company LinkedIn URL
|
||||
</label>
|
||||
<input
|
||||
@@ -187,7 +190,10 @@ export function Step1(props: Step1Props) {
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<label htmlFor="website" className="text-sm leading-none text-slate-500">
|
||||
<label
|
||||
htmlFor="website"
|
||||
className="text-sm leading-none text-slate-500"
|
||||
>
|
||||
GitHub Organization URL
|
||||
</label>
|
||||
<input
|
||||
@@ -221,11 +227,11 @@ export function Step1(props: Step1Props) {
|
||||
setTeamSize((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Select team size
|
||||
</option>
|
||||
<option value="">Select team size</option>
|
||||
{validTeamSizes.map((size) => (
|
||||
<option key={size} value={size}>{size} people</option>
|
||||
<option key={size} value={size}>
|
||||
{size} people
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Modal } from '../Modal.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { deleteOpenAIKey, getOpenAIKey, saveOpenAIKey } from '../../lib/jwt.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
@@ -17,7 +16,7 @@ export function OpenAISettings(props: OpenAISettingsProps) {
|
||||
|
||||
const [defaultOpenAIKey, setDefaultOpenAIKey] = useState('');
|
||||
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [openaiApiKey, setOpenaiApiKey] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -57,7 +56,7 @@ export function OpenAISettings(props: OpenAISettingsProps) {
|
||||
className="mt-4"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setHasError(false);
|
||||
setError('');
|
||||
|
||||
const normalizedKey = openaiApiKey.trim();
|
||||
if (!normalizedKey) {
|
||||
@@ -68,7 +67,7 @@ export function OpenAISettings(props: OpenAISettingsProps) {
|
||||
}
|
||||
|
||||
if (!normalizedKey.startsWith('sk-')) {
|
||||
setHasError(true);
|
||||
setError("Invalid OpenAI API key. It should start with 'sk-'");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -81,7 +80,7 @@ export function OpenAISettings(props: OpenAISettingsProps) {
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setHasError(true);
|
||||
setError(error.message);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -100,13 +99,13 @@ export function OpenAISettings(props: OpenAISettingsProps) {
|
||||
className={cn(
|
||||
'block w-full rounded-md border border-gray-300 px-3 py-2 text-gray-800 transition-colors focus:border-black focus:outline-none',
|
||||
{
|
||||
'border-red-500 bg-red-100 focus:border-red-500': hasError,
|
||||
'border-red-500 bg-red-100 focus:border-red-500': error,
|
||||
},
|
||||
)}
|
||||
placeholder="Enter your OpenAI API key"
|
||||
value={openaiApiKey}
|
||||
onChange={(e) => {
|
||||
setHasError(false);
|
||||
setError('');
|
||||
setOpenaiApiKey((e.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
@@ -127,9 +126,9 @@ export function OpenAISettings(props: OpenAISettingsProps) {
|
||||
We do not store your API key on our servers.
|
||||
</p>
|
||||
|
||||
{hasError && (
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-500">
|
||||
Please enter a valid OpenAI API key
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -34,7 +34,7 @@ export function RoadCardPage() {
|
||||
}
|
||||
|
||||
const badgeUrl = new URL(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-badge/${version}/${user?.id}`,
|
||||
`${import.meta.env.PUBLIC_APP_URL}/card/${version}/${user?.id}`,
|
||||
);
|
||||
|
||||
badgeUrl.searchParams.set('variant', variant);
|
||||
@@ -146,7 +146,7 @@ export function RoadCardPage() {
|
||||
className="flex cursor-pointer items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50"
|
||||
onClick={() => copyText(badgeUrl.toString())}
|
||||
>
|
||||
<CopyIcon size={16} className="inline-block h-4 w-4 mr-1" />
|
||||
<CopyIcon size={16} className="mr-1 inline-block h-4 w-4" />
|
||||
|
||||
{isCopied ? 'Copied!' : 'Copy Link'}
|
||||
</button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import type { TeamStreamActivity } from './TeamActivityPage';
|
||||
import { ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||
import { ActivityTopicTitles } from '../Activity/ActivityTopicTitles';
|
||||
|
||||
type TeamActivityItemProps = {
|
||||
onTopicClick?: (activity: TeamStreamActivity) => void;
|
||||
@@ -72,8 +73,8 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
|
||||
if (activities.length === 1) {
|
||||
const activity = activities[0];
|
||||
const { actionType, topicIds } = activity;
|
||||
const topicCount = topicIds?.length || 0;
|
||||
const { actionType, topicTitles } = activity;
|
||||
const topicCount = topicTitles?.length || 0;
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -83,12 +84,10 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
{actionType === 'in_progress' && (
|
||||
<>
|
||||
{username} started{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => onTopicClick?.(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
<ActivityTopicTitles
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => onTopicClick?.(activity)}
|
||||
/>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
</>
|
||||
)}
|
||||
@@ -96,12 +95,10 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
{actionType === 'done' && (
|
||||
<>
|
||||
{username} completed{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => onTopicClick?.(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
<ActivityTopicTitles
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => onTopicClick?.(activity)}
|
||||
/>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
</>
|
||||
)}
|
||||
@@ -131,32 +128,28 @@ export function TeamActivityItem(props: TeamActivityItemProps) {
|
||||
<div className="py-3">
|
||||
<ul className="ml-2 flex flex-col gap-2 sm:ml-[36px]">
|
||||
{activities.slice(0, activityLimit).map((activity) => {
|
||||
const { actionType, topicIds } = activity;
|
||||
const topicCount = topicIds?.length || 0;
|
||||
const { actionType, topicTitles } = activity;
|
||||
const topicCount = topicTitles?.length || 0;
|
||||
|
||||
return (
|
||||
<li key={activity._id} className="text-sm text-gray-600">
|
||||
{actionType === 'in_progress' && (
|
||||
<>
|
||||
Started{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => onTopicClick?.(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
<ActivityTopicTitles
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => onTopicClick?.(activity)}
|
||||
/>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
</>
|
||||
)}
|
||||
{actionType === 'done' && (
|
||||
<>
|
||||
Completed{' '}
|
||||
<button
|
||||
className="font-medium underline underline-offset-2 hover:text-black"
|
||||
onClick={() => onTopicClick?.(activity)}
|
||||
>
|
||||
{topicCount} topic{topicCount > 1 ? 's' : ''}
|
||||
</button>{' '}
|
||||
<ActivityTopicTitles
|
||||
topicTitles={topicTitles || []}
|
||||
onSelectActivity={() => onTopicClick?.(activity)}
|
||||
/>{' '}
|
||||
in {resourceLink(activity)} {timeAgo(activity.updatedAt)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@ export type TeamStreamActivity = {
|
||||
resourceSlug?: string;
|
||||
isCustomResource?: boolean;
|
||||
actionType: AllowedActivityActionType;
|
||||
topicIds?: string[];
|
||||
topicTitles?: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
@@ -98,38 +98,70 @@ export function TeamActivityPage() {
|
||||
}, [teamId]);
|
||||
|
||||
const { users, activities } = teamActivities?.data;
|
||||
const usersWithActivities = useMemo(() => {
|
||||
const validActivities = activities.filter((activity) => {
|
||||
const validActivities = useMemo(() => {
|
||||
return activities?.filter((activity) => {
|
||||
return (
|
||||
activity.activity.length > 0 &&
|
||||
activity.activity.some((t) => (t?.topicIds?.length || 0) > 0)
|
||||
activity.activity.some((t) => (t?.topicTitles?.length || 0) > 0)
|
||||
);
|
||||
});
|
||||
}, [activities]);
|
||||
|
||||
return users
|
||||
.map((user) => {
|
||||
const userActivities = validActivities
|
||||
.filter((activity) => activity.userId === user._id)
|
||||
.flatMap((activity) => activity.activity)
|
||||
.filter((activity) => (activity?.topicIds?.length || 0) > 0)
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
});
|
||||
const sortedUniqueCreatedAt = useMemo(() => {
|
||||
return new Set(
|
||||
validActivities
|
||||
?.map((activity) => new Date(activity.createdAt).setHours(0, 0, 0, 0))
|
||||
.sort((a, b) => {
|
||||
return new Date(b).getTime() - new Date(a).getTime();
|
||||
}),
|
||||
);
|
||||
}, [validActivities]);
|
||||
|
||||
return {
|
||||
...user,
|
||||
activities: userActivities,
|
||||
};
|
||||
})
|
||||
.filter((user) => user.activities.length > 0)
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.activities[0].updatedAt).getTime() -
|
||||
new Date(a.activities[0].updatedAt).getTime()
|
||||
);
|
||||
});
|
||||
const usersWithActivities = useMemo(() => {
|
||||
const enrichedUsers: {
|
||||
_id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
username?: string;
|
||||
activities: TeamStreamActivity[];
|
||||
}[] = [];
|
||||
|
||||
for (const uniqueCreatedAt of sortedUniqueCreatedAt) {
|
||||
const uniqueActivities = validActivities.filter(
|
||||
(activity) =>
|
||||
new Date(activity.createdAt).setHours(0, 0, 0, 0) === uniqueCreatedAt,
|
||||
);
|
||||
|
||||
const usersWithUniqueActivities = users
|
||||
.map((user) => {
|
||||
const userActivities = uniqueActivities
|
||||
.filter((activity) => activity.userId === user._id)
|
||||
.flatMap((activity) => activity.activity)
|
||||
.filter((activity) => (activity?.topicTitles?.length || 0) > 0)
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.updatedAt).getTime() -
|
||||
new Date(a.updatedAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...user,
|
||||
activities: userActivities,
|
||||
};
|
||||
})
|
||||
.filter((user) => user.activities.length > 0)
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.activities[0].updatedAt).getTime() -
|
||||
new Date(a.activities[0].updatedAt).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
enrichedUsers.push(...usersWithUniqueActivities);
|
||||
}
|
||||
|
||||
return enrichedUsers;
|
||||
}, [users, activities]);
|
||||
|
||||
if (!teamId) {
|
||||
|
||||
@@ -16,54 +16,10 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
topicIds = [],
|
||||
topicTitles = [],
|
||||
actionType,
|
||||
} = activity;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [topicTitles, setTopicTitles] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadTopicTitles = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-topic-titles`,
|
||||
{
|
||||
resourceId,
|
||||
resourceType,
|
||||
isCustomResource,
|
||||
topicIds,
|
||||
},
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
setError(error?.message || 'Failed to load topic titles');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTopicTitles(response);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTopicTitles().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading || error) {
|
||||
return (
|
||||
<ModalLoader
|
||||
error={error!}
|
||||
text={'Loading topics..'}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let pageUrl = '';
|
||||
if (resourceType === 'roadmap') {
|
||||
pageUrl = isCustomResource ? `/r/${resourceId}` : `/${resourceId}`;
|
||||
@@ -77,8 +33,6 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
|
||||
<Modal
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}}
|
||||
>
|
||||
<div className={`popup-body relative rounded-lg bg-white p-4 shadow`}>
|
||||
@@ -100,9 +54,7 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
|
||||
</a>
|
||||
</span>
|
||||
<ul className="flex max-h-[50vh] flex-col gap-1 overflow-y-auto max-md:max-h-full">
|
||||
{topicIds.map((topicId) => {
|
||||
const topicTitle = topicTitles[topicId] || 'Unknown Topic';
|
||||
|
||||
{topicTitles.map((topicTitle) => {
|
||||
const ActivityIcon =
|
||||
actionType === 'done'
|
||||
? Check
|
||||
@@ -111,7 +63,7 @@ export function TeamActivityTopicsModal(props: TeamActivityTopicsModalProps) {
|
||||
: Check;
|
||||
|
||||
return (
|
||||
<li key={topicId} className="flex items-start gap-2">
|
||||
<li key={topicTitle} className="flex items-start gap-2">
|
||||
<ActivityIcon
|
||||
strokeWidth={3}
|
||||
className="relative top-[4px] text-green-500"
|
||||
|
||||
@@ -24,6 +24,7 @@ export function UpdateTeamForm() {
|
||||
const [gitHub, setGitHub] = useState('');
|
||||
const [teamType, setTeamType] = useState('');
|
||||
const [teamSize, setTeamSize] = useState('');
|
||||
const [personalProgressOnly, setPersonalProgressOnly] = useState(false);
|
||||
const validTeamSizes = [
|
||||
'0-1',
|
||||
'2-10',
|
||||
@@ -55,11 +56,12 @@ export function UpdateTeamForm() {
|
||||
website,
|
||||
type: teamType,
|
||||
gitHubUrl: gitHub || undefined,
|
||||
personalProgressOnly,
|
||||
...(teamType === 'company' && {
|
||||
teamSize,
|
||||
linkedInUrl: linkedIn || undefined,
|
||||
}),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
@@ -77,7 +79,7 @@ export function UpdateTeamForm() {
|
||||
|
||||
async function loadTeam() {
|
||||
const { response, error } = await httpGet<TeamDocument>(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamId}`
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-get-team/${teamId}`,
|
||||
);
|
||||
if (error || !response) {
|
||||
console.log(error);
|
||||
@@ -90,6 +92,7 @@ export function UpdateTeamForm() {
|
||||
setLinkedIn(response?.links?.linkedIn || '');
|
||||
setGitHub(response?.links?.github || '');
|
||||
setTeamType(response.type);
|
||||
setPersonalProgressOnly(response.personalProgressOnly ?? false);
|
||||
if (response.teamSize) {
|
||||
setTeamSize(response.teamSize);
|
||||
}
|
||||
@@ -205,16 +208,14 @@ export function UpdateTeamForm() {
|
||||
<select
|
||||
name="type"
|
||||
id="type"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
className="mt-2 block h-[42px] w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
disabled={isDisabled}
|
||||
value={teamType || ''}
|
||||
onChange={(e) =>
|
||||
setTeamType((e.target as HTMLSelectElement).value as any)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
Select type
|
||||
</option>
|
||||
<option value="">Select type</option>
|
||||
<option value="company">Company</option>
|
||||
<option value="study_group">Study Group</option>
|
||||
</select>
|
||||
@@ -231,7 +232,7 @@ export function UpdateTeamForm() {
|
||||
<select
|
||||
name="team-size"
|
||||
id="team-size"
|
||||
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
className="mt-2 block h-[42px] w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
|
||||
required={teamType === 'company'}
|
||||
disabled={isDisabled}
|
||||
value={teamSize}
|
||||
@@ -249,6 +250,31 @@ export function UpdateTeamForm() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex h-[42px] w-full items-center rounded-lg border border-gray-300 px-3 py-2 shadow-sm">
|
||||
<label
|
||||
htmlFor="personal-progress-only"
|
||||
className="flex items-center gap-2 text-sm leading-none text-slate-500"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="personal-progress-only"
|
||||
id="personal-progress-only"
|
||||
disabled={isDisabled}
|
||||
checked={personalProgressOnly}
|
||||
onChange={(e) =>
|
||||
setPersonalProgressOnly((e.target as HTMLInputElement).checked)
|
||||
}
|
||||
/>
|
||||
<span>Members can only see their personal progress</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{personalProgressOnly && (
|
||||
<p className="mt-2 rounded-lg border border-orange-300 bg-orange-50 p-2 text-sm text-orange-700">
|
||||
Only admins and managers will be able to see the progress of members
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -6,7 +6,7 @@ excludedBySlug: '/java/developer-skills'
|
||||
seo:
|
||||
title: 'Must-Have Java Full-stack Developer Skills in @currentYear@'
|
||||
description: 'Master the essential skills every Java full stack developer needs. Boost your career with our expert tips!'
|
||||
ogImageUrl: 'https://assets.roadmap.sh/guest/java-full-stack-developer-skills-yctex.png'
|
||||
ogImageUrl: 'https://assets.roadmap.sh/guest/java-full-stack-developer-skills-sjzbd.png'
|
||||
isNew: true
|
||||
type: 'textual'
|
||||
date: 2024-05-01
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Insertion Sort
|
||||
|
||||
Insertion sort is a simple sorting algorithm that builds the final sorted array (or list) one item at a time. It's much less efficient on large lists than more advanced algorithms like quicksort, heapsort, or merge sort. Still, it provides several advantages such as it's easy to understand the algorithm, it performs well with small lists or lists that are already partially sorted and it can sort the list as it receives it. The algorithm iterates, consuming one input element each repetition and growing a sorted output list. At each iteration, it removes one element from the input data, finds the location it belongs within the sorted list and inserts it there. It repeats until no input elements remain.
|
||||
Insertion sort is a simple sorting algorithm that builds the final sorted array (or list) one item at a time. It's much less efficient on large lists than more advanced algorithms like quicksort, heapsort, or merge sort. Still, it provides several advantages such as it's easy to understand the algorithm, it performs well with small lists or lists that are already partially sorted and it can sort the list as it receives it. The algorithm iterates, consuming one input element each repetition and growing a sorted output list. At each iteration, it removes one element from the input data, finds the location it belongs within the sorted list and inserts it there. It repeats until no input elements remain.
|
||||
|
||||
- [Insertion Sort - W3Schools](https://www.w3schools.com/dsa/dsa_algo_insertionsort.php)
|
||||
|
||||
@@ -5,4 +5,3 @@ It is a feature in Kubernetes that automatically scales the number of replicas o
|
||||
Learn more from the following resources:
|
||||
|
||||
- [Horizontal Pod Autoscaling - Documentation](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/)
|
||||
- [Kubernetes Horizontal Pod Autoscaling - Kubernetes Tutorials](https://www.youtube.com/watch?v=hm3jnETOoFo)
|
||||
|
||||
@@ -9,7 +9,7 @@ The main benefits of using TypeScript include:
|
||||
- Improved Maintainability
|
||||
- Backwards Compatibility
|
||||
|
||||
Learn more from the folowing links:
|
||||
Learn more from the following links:
|
||||
|
||||
- [Overview of TypeScript](https://www.typescriptlang.org/docs/handbook/typescript-from-scratch.html)
|
||||
- [TypeScript Handbook](https://www.typescriptlang.org/docs/handbook/typescript-from-scratch.html)
|
||||
|
||||
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@@ -4,6 +4,7 @@
|
||||
interface ImportMetaEnv {
|
||||
GITHUB_SHA: string;
|
||||
PUBLIC_API_URL: string;
|
||||
PUBLIC_APP_URL: string;
|
||||
PUBLIC_AVATAR_BASE_URL: string;
|
||||
PUBLIC_EDITOR_APP_URL: string;
|
||||
}
|
||||
|
||||
17
src/lib/road-card.ts
Normal file
17
src/lib/road-card.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export async function getRoadCard(
|
||||
version: 'tall' | 'wide',
|
||||
userId: string,
|
||||
variant: 'dark' | 'light',
|
||||
roadmaps: string = '',
|
||||
) {
|
||||
const url = new URL(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-badge/${version}/${userId}`,
|
||||
);
|
||||
url.searchParams.set('variant', variant);
|
||||
if (roadmaps) {
|
||||
url.searchParams.set('roadmaps', roadmaps);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
return response.text();
|
||||
}
|
||||
34
src/pages/card/[version]/[userId].ts
Normal file
34
src/pages/card/[version]/[userId].ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDefaultOpenGraphImageBuffer } from '../../../lib/open-graph';
|
||||
import { getRoadCard } from '../../../lib/road-card';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
type Params = {
|
||||
version: 'tall' | 'wide';
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export const GET: APIRoute<any, Params> = async (context) => {
|
||||
const { userId, version } = context.params;
|
||||
|
||||
if (!userId || !version) {
|
||||
const buffer = await getDefaultOpenGraphImageBuffer();
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(context.url.searchParams);
|
||||
const variant = (searchParams.get('variant') as 'dark' | 'light') || 'dark';
|
||||
const roadmaps = searchParams.get('roadmaps') || '';
|
||||
|
||||
const svg = await getRoadCard(version, userId, variant, roadmaps);
|
||||
return new Response(svg, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
|
||||
Reference in New Issue
Block a user