add community page
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
"main": ".vite/build/main.js",
|
||||
"types": "./src/types.d.ts",
|
||||
"scripts": {
|
||||
"dev": "rimraf .vite && WEB_API_URL=http://localhost:3000 electron-forge start",
|
||||
"dev": "rimraf .vite && WEB_API_URL=http://192.168.31.116:3000 electron-forge start",
|
||||
"start": "rimraf .vite && electron-forge start",
|
||||
"package": "rimraf .vite && electron-forge package",
|
||||
"make": "rimraf .vite && electron-forge make",
|
||||
|
||||
230
enjoy/src/api/client.ts
Normal file
230
enjoy/src/api/client.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import decamelizeKeys from "decamelize-keys";
|
||||
import camelcaseKeys from "camelcase-keys";
|
||||
|
||||
const ONE_MINUTE = 1000 * 60; // 1 minute
|
||||
|
||||
export class Client {
|
||||
public api: AxiosInstance;
|
||||
|
||||
constructor(options: { baseUrl: string; accessToken?: string }) {
|
||||
const { baseUrl, accessToken } = options;
|
||||
|
||||
this.api = axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: ONE_MINUTE,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
this.api.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
|
||||
console.info(
|
||||
config.method.toUpperCase(),
|
||||
config.baseURL + config.url,
|
||||
config.data,
|
||||
config.params
|
||||
);
|
||||
return config;
|
||||
});
|
||||
this.api.interceptors.response.use(
|
||||
(response) => {
|
||||
console.info(
|
||||
response.status,
|
||||
response.config.method.toUpperCase(),
|
||||
response.config.baseURL + response.config.url
|
||||
);
|
||||
return camelcaseKeys(response.data, { deep: true });
|
||||
},
|
||||
(err) => {
|
||||
if (err.response) {
|
||||
console.error(
|
||||
err.response.status,
|
||||
err.response.config.method.toUpperCase(),
|
||||
err.response.config.baseURL + err.response.config.url
|
||||
);
|
||||
console.error(err.response.data);
|
||||
return Promise.reject(err.response.data);
|
||||
}
|
||||
|
||||
if (err.request) {
|
||||
console.error(err.request);
|
||||
} else {
|
||||
console.error(err.message);
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
auth(params: { provider: string; code: string }): Promise<UserType> {
|
||||
return this.api.post("/api/sessions", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
me() {
|
||||
return this.api.get("/api/me");
|
||||
}
|
||||
|
||||
rankings(range: "day" | "week" | "month" | "year" | "all" = "day"): Promise<{
|
||||
rankings: UserType[];
|
||||
range: string;
|
||||
}> {
|
||||
return this.api.get("/api/users/rankings", { params: { range } });
|
||||
}
|
||||
|
||||
posts(params?: { page?: number; items?: number }): Promise<
|
||||
{
|
||||
posts: PostType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/posts", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
post(id: string): Promise<PostType> {
|
||||
return this.api.get(`/api/posts/${id}`);
|
||||
}
|
||||
|
||||
createPost(params: { content: string }): Promise<PostType> {
|
||||
return this.api.post("/api/posts", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
updatePost(id: string, params: { content: string }): Promise<PostType> {
|
||||
return this.api.put(`/api/posts/${id}`, decamelizeKeys(params));
|
||||
}
|
||||
|
||||
deletePost(id: string): Promise<void> {
|
||||
return this.api.delete(`/api/posts/${id}`);
|
||||
}
|
||||
|
||||
syncAudio(audio: Partial<AudioType>) {
|
||||
return this.api.post("/api/mine/audios", decamelizeKeys(audio));
|
||||
}
|
||||
|
||||
syncVideo(video: Partial<VideoType>) {
|
||||
return this.api.post("/api/mine/videos", decamelizeKeys(video));
|
||||
}
|
||||
|
||||
syncTranscription(transcription: Partial<TranscriptionType>) {
|
||||
return this.api.post("/api/transcriptions", decamelizeKeys(transcription));
|
||||
}
|
||||
|
||||
syncRecording(recording: Partial<RecordingType>) {
|
||||
if (!recording) return;
|
||||
|
||||
return this.api.post("/api/mine/recordings", decamelizeKeys(recording));
|
||||
}
|
||||
|
||||
generateSpeechToken(): Promise<{ token: string; region: string }> {
|
||||
return this.api.post("/api/speech/tokens");
|
||||
}
|
||||
|
||||
syncPronunciationAssessment(
|
||||
pronunciationAssessment: Partial<PronunciationAssessmentType>
|
||||
) {
|
||||
if (!pronunciationAssessment) return;
|
||||
|
||||
return this.api.post(
|
||||
"/api/mine/pronunciation_assessments",
|
||||
decamelizeKeys(pronunciationAssessment)
|
||||
);
|
||||
}
|
||||
|
||||
recordingAssessment(id: string) {
|
||||
return this.api.get(`/api/mine/recordings/${id}/assessment`);
|
||||
}
|
||||
|
||||
lookup(params: {
|
||||
word: string;
|
||||
context: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}): Promise<LookupType> {
|
||||
return this.api.post("/api/lookups", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
lookupInBatch(
|
||||
lookups: {
|
||||
word: string;
|
||||
context: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}[]
|
||||
): Promise<{ successCount: number; total: number }> {
|
||||
return this.api.post("/api/lookups/batch", {
|
||||
lookups: decamelizeKeys(lookups, { deep: true }),
|
||||
});
|
||||
}
|
||||
|
||||
extractVocabularyFromStory(storyId: string): Promise<string[]> {
|
||||
return this.api.post(`/api/stories/${storyId}/extract_vocabulary`);
|
||||
}
|
||||
|
||||
storyMeanings(
|
||||
storyId: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
storyId?: string;
|
||||
}
|
||||
): Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get(`/api/stories/${storyId}/meanings`, {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
mineMeanings(params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
status?: string;
|
||||
}): Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/mine/meanings", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
createStory(params: CreateStoryParamsType): Promise<StoryType> {
|
||||
return this.api.post("/api/stories", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
story(id: string): Promise<StoryType> {
|
||||
return this.api.get(`/api/stories/${id}`);
|
||||
}
|
||||
|
||||
stories(params?: { page: number }): Promise<
|
||||
{
|
||||
stories: StoryType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/stories", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
mineStories(params?: { page: number }): Promise<
|
||||
{
|
||||
stories: StoryType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/mine/stories", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
starStory(storyId: string) {
|
||||
return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId }));
|
||||
}
|
||||
|
||||
unstarStory(storyId: string) {
|
||||
return this.api.delete(`/api/mine/stories/${storyId}`);
|
||||
}
|
||||
}
|
||||
1
enjoy/src/api/index.ts
Normal file
1
enjoy/src/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./client";
|
||||
@@ -122,6 +122,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"home": "Home",
|
||||
"community": "Community",
|
||||
"audios": "Audios",
|
||||
"videos": "Videos",
|
||||
"stories": "Stories",
|
||||
@@ -237,7 +238,7 @@
|
||||
"recentlyAdded": "recently added",
|
||||
"recommended": "recommended",
|
||||
"resourcesRecommendedByEnjoy": "resources recommended by Enjoy Bot",
|
||||
"fromCommunity": "from commnuity",
|
||||
"fromCommunity": "from community",
|
||||
"videoResources": "video resources",
|
||||
"audioResources": "audio resources",
|
||||
"seeMore": "see more",
|
||||
@@ -320,5 +321,13 @@
|
||||
"presenter": "presenter",
|
||||
"downloadAudio": "Download audio",
|
||||
"downloadVideo": "Download video",
|
||||
"recordTooShort": "Record too short"
|
||||
"recordTooShort": "Record too short",
|
||||
"rankings": "Rankings",
|
||||
"dayRankings": "Day rankings",
|
||||
"weekRankings": "Week rankings",
|
||||
"monthRankings": "Month rankings",
|
||||
"allRankings": "All time rankings",
|
||||
"noOneHasRecordedYet": "No one has recorded yet",
|
||||
"activities": "Activities",
|
||||
"noOneSharedYet": "No one shared yet"
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"home": "主页",
|
||||
"community": "社区",
|
||||
"audios": "音频",
|
||||
"videos": "视频",
|
||||
"stories": "文章",
|
||||
@@ -320,5 +321,13 @@
|
||||
"presenter": "讲者",
|
||||
"downloadAudio": "下载音频",
|
||||
"downloadVideo": "下载视频",
|
||||
"recordTooShort": "录音时长太短"
|
||||
"recordTooShort": "录音时长太短",
|
||||
"rankings": "排行榜",
|
||||
"dayRankings": "日排行榜",
|
||||
"weekRankings": "周排行榜",
|
||||
"monthRankings": "月排行榜",
|
||||
"allRankings": "总排行榜",
|
||||
"noOneHasRecordedYet": "还没有人练习",
|
||||
"activities": "动态",
|
||||
"noOneSharedYet": "还没有人分享"
|
||||
}
|
||||
|
||||
@@ -63,12 +63,43 @@ class WebApi {
|
||||
);
|
||||
}
|
||||
|
||||
auth(params: { provider: string; code: string }): Promise<UserType> {
|
||||
return this.api.post("/api/sessions", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
me() {
|
||||
return this.api.get("/api/me");
|
||||
}
|
||||
|
||||
auth(params: { provider: string; code: string }): Promise<UserType> {
|
||||
return this.api.post("/api/sessions", decamelizeKeys(params));
|
||||
rankings(range: "day" | "week" | "month" | "year" | "all" = "day"): Promise<{
|
||||
rankings: UserType[];
|
||||
range: string;
|
||||
}> {
|
||||
return this.api.get("/api/users/rankings", { params: { range } });
|
||||
}
|
||||
|
||||
posts(params?: { page?: number; items?: number }): Promise<
|
||||
{
|
||||
posts: PostType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/posts", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
post(id: string): Promise<PostType> {
|
||||
return this.api.get(`/api/posts/${id}`);
|
||||
}
|
||||
|
||||
createPost(params: { content: string }): Promise<PostType> {
|
||||
return this.api.post("/api/posts", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
updatePost(id: string, params: { content: string }): Promise<PostType> {
|
||||
return this.api.put(`/api/posts/${id}`, decamelizeKeys(params));
|
||||
}
|
||||
|
||||
deletePost(id: string): Promise<void> {
|
||||
return this.api.delete(`/api/posts/${id}`);
|
||||
}
|
||||
|
||||
syncAudio(audio: Partial<AudioType>) {
|
||||
@@ -228,6 +259,76 @@ class WebApi {
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-rankings", async (event, range) => {
|
||||
return this.rankings(range)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(error);
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-posts", async (event, params) => {
|
||||
return this.posts(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(error);
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-post", async (event, id) => {
|
||||
return this.post(id)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(error);
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-create-post", async (event, params) => {
|
||||
return this.createPost(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(error);
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-delete-post", async (event, id) => {
|
||||
return this.deletePost(id)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(error);
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-lookup", async (event, params) => {
|
||||
return this.lookup(params)
|
||||
.then((response) => {
|
||||
@@ -296,21 +397,18 @@ class WebApi {
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"web-api-story-meanings",
|
||||
async (event, storyId, params) => {
|
||||
return this.storyMeanings(storyId, params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
ipcMain.handle("web-api-story-meanings", async (event, storyId, params) => {
|
||||
return this.storyMeanings(storyId, params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-stories", async (event, params) => {
|
||||
return this.stories(params)
|
||||
|
||||
@@ -10,6 +10,9 @@ export * from "./videos";
|
||||
|
||||
export * from "./medias";
|
||||
|
||||
export * from "./posts";
|
||||
export * from "./users";
|
||||
|
||||
export * from "./db-state";
|
||||
|
||||
export * from "./layout";
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Button, useToast } from "@renderer/components/ui";
|
||||
import { useContext, useState, useEffect } from "react";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const LoginForm = () => {
|
||||
const { toast } = useToast();
|
||||
const { EnjoyApp, login } = useContext(AppSettingsProviderContext);
|
||||
const [endpoint, setEndpoint] = useState(WEB_API_URL);
|
||||
const { EnjoyApp, login, apiUrl } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const handleMixinLogin = () => {
|
||||
const url = `${endpoint}/sessions/new?provider=mixin`;
|
||||
const url = `${apiUrl}/sessions/new?provider=mixin`;
|
||||
EnjoyApp.view.load(url, { x: 0, y: 0 });
|
||||
};
|
||||
|
||||
@@ -36,7 +34,7 @@ export const LoginForm = () => {
|
||||
const provider = new URL(url).pathname.split("/")[2];
|
||||
const code = new URL(url).searchParams.get("code");
|
||||
|
||||
if (!url.startsWith(endpoint)) {
|
||||
if (!url.startsWith(apiUrl)) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("invalidRedirectUrl"),
|
||||
@@ -65,12 +63,6 @@ export const LoginForm = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.app.apiUrl().then((url) => {
|
||||
setEndpoint(url);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.view.onViewState((_event, state) => onViewState(state));
|
||||
|
||||
@@ -78,7 +70,7 @@ export const LoginForm = () => {
|
||||
EnjoyApp.view.removeViewStateListeners();
|
||||
EnjoyApp.view.remove();
|
||||
};
|
||||
}, [endpoint]);
|
||||
}, [apiUrl]);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm px-6 flex flex-col space-y-4">
|
||||
|
||||
1
enjoy/src/renderer/components/posts/index.ts
Normal file
1
enjoy/src/renderer/components/posts/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './posts';
|
||||
37
enjoy/src/renderer/components/posts/posts.tsx
Normal file
37
enjoy/src/renderer/components/posts/posts.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Client } from "@/api";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const Posts = () => {
|
||||
const { apiUrl, user } = useContext(AppSettingsProviderContext);
|
||||
const [posts, setPosts] = useState<PostType[]>([]);
|
||||
|
||||
const client = new Client({
|
||||
baseUrl: apiUrl,
|
||||
accessToken: user.accessToken,
|
||||
});
|
||||
|
||||
const fetchPosts = async () => {
|
||||
client.posts().then(
|
||||
(res) => {
|
||||
setPosts(res.posts);
|
||||
},
|
||||
(err) => {
|
||||
console.error(err);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center text-gray-500">{t("noOneSharedYet")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
BookMarkedIcon,
|
||||
UserIcon,
|
||||
BotIcon,
|
||||
UsersRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
@@ -50,6 +51,21 @@ export const Sidebar = () => {
|
||||
<span className="hidden xl:block">{t("sidebar.home")}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/community"
|
||||
data-tooltip-id="sidebar-tooltip"
|
||||
data-tooltip-content={t("sidebar.community")}
|
||||
className="block"
|
||||
>
|
||||
<Button
|
||||
variant={activeTab === "" ? "secondary" : "ghost"}
|
||||
className="w-full xl:justify-start"
|
||||
>
|
||||
<UsersRoundIcon className="xl:mr-2 h-5 w-5" />
|
||||
<span className="hidden xl:block">{t("sidebar.community")}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
1
enjoy/src/renderer/components/users/index.ts
Normal file
1
enjoy/src/renderer/components/users/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './users-rankings';
|
||||
89
enjoy/src/renderer/components/users/users-rankings.tsx
Normal file
89
enjoy/src/renderer/components/users/users-rankings.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Card,
|
||||
CardTitle,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
} from "@renderer/components/ui";
|
||||
import { Client } from "@/api";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { formatDuration } from "@renderer/lib/utils";
|
||||
|
||||
export const UsersRankings = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<RankingsCard range="day" />
|
||||
<RankingsCard range="week" />
|
||||
<RankingsCard range="month" />
|
||||
<RankingsCard range="all" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RankingsCard = (props: {
|
||||
range: "day" | "week" | "month" | "year" | "all";
|
||||
}) => {
|
||||
const { range } = props;
|
||||
const { apiUrl, user } = useContext(AppSettingsProviderContext);
|
||||
const [rankings, setRankings] = useState<UserType[]>([]);
|
||||
|
||||
const client = new Client({
|
||||
baseUrl: apiUrl,
|
||||
accessToken: user.accessToken,
|
||||
});
|
||||
|
||||
const fetchRankings = async () => {
|
||||
client.rankings(range).then(
|
||||
(res) => {
|
||||
setRankings(res.rankings);
|
||||
},
|
||||
(err) => {
|
||||
console.error(err);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRankings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t(`${range}Rankings`)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rankings.length === 0 && (
|
||||
<div className="text-center text-gray-500">
|
||||
{t("noOneHasRecordedYet")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rankings.map((user, index) => (
|
||||
<div key={user.id} className="flex items-center space-x-4 px-4 py-2">
|
||||
<div className="font-mono text-sm">#{index + 1}</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar>
|
||||
<AvatarImage src={user.avatarUrl} />
|
||||
<AvatarFallback className="text-xl">
|
||||
{user.name[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="max-w-20 truncate">{user.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 font-serif text-right">
|
||||
{formatDuration(user.recordingsDuration, "millisecond")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
|
||||
type AppSettingsProviderState = {
|
||||
apiUrl: string;
|
||||
user: UserType | null;
|
||||
initialized: boolean;
|
||||
version?: string;
|
||||
@@ -17,6 +19,7 @@ type AppSettingsProviderState = {
|
||||
};
|
||||
|
||||
const initialState: AppSettingsProviderState = {
|
||||
apiUrl: WEB_API_URL,
|
||||
user: null,
|
||||
initialized: false,
|
||||
};
|
||||
@@ -31,6 +34,7 @@ export const AppSettingsProvider = ({
|
||||
}) => {
|
||||
const [initialized, setInitialized] = useState<boolean>(false);
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const [apiUrl, setApiUrl] = useState<string>(WEB_API_URL);
|
||||
const [user, setUser] = useState<UserType | null>(null);
|
||||
const [libraryPath, setLibraryPath] = useState("");
|
||||
const [whisperModelsPath, setWhisperModelsPath] = useState<string>("");
|
||||
@@ -44,6 +48,7 @@ export const AppSettingsProvider = ({
|
||||
fetchLibraryPath();
|
||||
fetchModel();
|
||||
fetchFfmpegConfig();
|
||||
fetchApiUrl();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -107,6 +112,11 @@ export const AppSettingsProvider = ({
|
||||
setWhisperModel(whisperModel);
|
||||
};
|
||||
|
||||
const fetchApiUrl = async () => {
|
||||
const apiUrl = await EnjoyApp.app.apiUrl();
|
||||
setApiUrl(apiUrl);
|
||||
}
|
||||
|
||||
const setModelHandler = async (name: string) => {
|
||||
await EnjoyApp.settings.setWhisperModel(name);
|
||||
setWhisperModel(name);
|
||||
@@ -123,6 +133,7 @@ export const AppSettingsProvider = ({
|
||||
value={{
|
||||
EnjoyApp,
|
||||
version,
|
||||
apiUrl,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
|
||||
@@ -3,10 +3,12 @@ import { twMerge } from "tailwind-merge";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import duration, { type DurationUnitType } from "dayjs/plugin/duration";
|
||||
import "dayjs/locale/en";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import i18next, { t } from "i18next";
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
@@ -18,6 +20,23 @@ export function secondsToTimestamp(seconds: number) {
|
||||
return date.toISOString().substr(11, 8);
|
||||
}
|
||||
|
||||
export function humanizeDuration(
|
||||
duration: number,
|
||||
unit: DurationUnitType = "second"
|
||||
) {
|
||||
dayjs.locale(i18next.resolvedLanguage?.toLowerCase() || "en");
|
||||
return dayjs.duration(duration, unit).humanize();
|
||||
}
|
||||
|
||||
export function formatDuration(
|
||||
duration: number,
|
||||
unit: DurationUnitType = "second",
|
||||
format = "HH:mm:ss"
|
||||
) {
|
||||
dayjs.locale(i18next.resolvedLanguage?.toLowerCase() || "en");
|
||||
return dayjs.duration(duration, unit).format(format);
|
||||
}
|
||||
|
||||
export function bytesToSize(bytes: number) {
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
if (bytes === 0) {
|
||||
|
||||
41
enjoy/src/renderer/pages/community.tsx
Normal file
41
enjoy/src/renderer/pages/community.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Button,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsContent,
|
||||
TabsTrigger,
|
||||
} from "@renderer/components/ui";
|
||||
import { UsersRankings, Posts } from "@renderer/components";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="h-full max-w-5xl mx-auto px-4 py-6">
|
||||
<div className="flex space-x-1 items-center mb-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<span>{t("sidebar.community")}</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="activities">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="activities">{t("activities")}</TabsTrigger>
|
||||
<TabsTrigger value="rankings">{t("rankings")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="activities">
|
||||
<Posts />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rankings">
|
||||
<UsersRankings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import Story from "./pages/story";
|
||||
import Books from "./pages/books";
|
||||
import Profile from "./pages/profile";
|
||||
import Home from "./pages/home";
|
||||
import Community from "./pages/community";
|
||||
import StoryPreview from "./pages/story-preview";
|
||||
|
||||
export default createHashRouter([
|
||||
@@ -23,6 +24,10 @@ export default createHashRouter([
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{
|
||||
path: "/community",
|
||||
element: <Community />,
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
element: <Profile />,
|
||||
|
||||
7
enjoy/src/types/post.d.ts
vendored
Normal file
7
enjoy/src/types/post.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
type PostType = {
|
||||
id: string;
|
||||
content?: string;
|
||||
user: UserType;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
2
enjoy/src/types/user.d.ts
vendored
2
enjoy/src/types/user.d.ts
vendored
@@ -3,4 +3,6 @@ type UserType = {
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
accessToken?: string;
|
||||
recordingsCount?: number;
|
||||
recordingsDuration?: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user