mirror of
https://github.com/ragestudio/comty.git
synced 2025-07-09 01:04:16 +00:00
Merge pull request #158 from ragestudio/dev
This commit is contained in:
commit
dedf1856a9
3
.gitignore
vendored
3
.gitignore
vendored
@ -44,3 +44,6 @@
|
||||
|
||||
# .vscode folder
|
||||
.vscode/
|
||||
|
||||
# zed folder
|
||||
.zed/
|
2
comty.js
2
comty.js
@ -1 +1 @@
|
||||
Subproject commit cbb45df2ef42205022e38e4c7d33a001e162c383
|
||||
Subproject commit 0dd24fee4a231dbb41d5b14ff92b67d8f14cb5a2
|
@ -1 +1 @@
|
||||
Subproject commit fa61273d5b4b40a22d97c7773321d8ca6c985fd7
|
||||
Subproject commit 1c4291928a3286a6c1d5ed0a4c3f7ac3eb87d45d
|
1
packages/app/.gitignore
vendored
1
packages/app/.gitignore
vendored
@ -8,5 +8,6 @@ yarn-error.log
|
||||
out/
|
||||
.ssl
|
||||
|
||||
src/pages/_debug
|
||||
public/oss-licenses.json
|
||||
/**/**/src/cores/@*
|
||||
|
52
packages/app/config/excuses.json
Normal file
52
packages/app/config/excuses.json
Normal file
@ -0,0 +1,52 @@
|
||||
[
|
||||
"The code elves are on strike.",
|
||||
"Our hamster-powered server needs a snack.",
|
||||
"404: Excuse not found.",
|
||||
"It worked on my machine!",
|
||||
"The internet gnomes are at it again.",
|
||||
"A wizard did it.",
|
||||
"We ran out of coffee.",
|
||||
"The app tripped over a semicolon.",
|
||||
"Gremlins in the wires.",
|
||||
"Someone forgot to feed the bugs.",
|
||||
"The cloud is feeling moody.",
|
||||
"Our AI is taking a nap.",
|
||||
"The server went out for pizza.",
|
||||
"Cosmic rays flipped a bit.",
|
||||
"The code is allergic to Mondays.",
|
||||
"The database is on vacation.",
|
||||
"The error is a feature, not a bug.",
|
||||
"The app is playing hide and seek.",
|
||||
"Our code monkey is on a banana break.",
|
||||
"The server is stuck in traffic.",
|
||||
"The app is updating its status.",
|
||||
"The error is shy, please try again.",
|
||||
"The code is practicing social distancing.",
|
||||
"The server is meditating.",
|
||||
"The app is on a coffee break.",
|
||||
"The error is lost in translation.",
|
||||
"The code is feeling existential.",
|
||||
"The server is chasing butterflies.",
|
||||
"The app is stuck in a time loop.",
|
||||
"The error is on vacation.",
|
||||
"The code is having an identity crisis.",
|
||||
"The server is out to lunch.",
|
||||
"The app is waiting for a sign.",
|
||||
"The error is hiding from QA.",
|
||||
"The code is stuck in traffic.",
|
||||
"The server is updating its horoscope.",
|
||||
"The app is busy counting sheep.",
|
||||
"The error is on a coffee run.",
|
||||
"The code is taking a power nap.",
|
||||
"The server is playing hide and seek.",
|
||||
"The app is lost in thought.",
|
||||
"The error is practicing yoga.",
|
||||
"The code is on a coffee detox.",
|
||||
"The server is binge-watching cat videos.",
|
||||
"The app is updating its playlist.",
|
||||
"The error is out of office.",
|
||||
"The code is on a lunch break.",
|
||||
"The server is dreaming in binary.",
|
||||
"The app is waiting for inspiration.",
|
||||
"The error is writing its memoirs."
|
||||
]
|
@ -1,611 +1,8 @@
|
||||
{
|
||||
"ab": "Abkhazian",
|
||||
"ace": "Achinese",
|
||||
"ach": "Acoli",
|
||||
"ada": "Adangme",
|
||||
"ady": "Adyghe",
|
||||
"aa": "Afar",
|
||||
"afh": "Afrihili",
|
||||
"af": "Afrikaans",
|
||||
"agq": "Aghem",
|
||||
"ain": "Ainu",
|
||||
"ak": "Akan",
|
||||
"akk": "Akkadian",
|
||||
"bss": "Akoose",
|
||||
"akz": "Alabama",
|
||||
"sq": "Albanian",
|
||||
"ale": "Aleut",
|
||||
"arq": "Algerian Arabic",
|
||||
"am": "Amarik",
|
||||
"en_US": "American English",
|
||||
"ase": "American Sign Language",
|
||||
"egy": "Ancient Egyptian",
|
||||
"grc": "Ancient Greek",
|
||||
"anp": "Angika",
|
||||
"njo": "Ao Naga",
|
||||
"ar": "Arabik",
|
||||
"an": "Aragonese",
|
||||
"arc": "Aramaic",
|
||||
"aro": "Araona",
|
||||
"arp": "Arapaho",
|
||||
"arw": "Arawak",
|
||||
"hy": "Armenian",
|
||||
"rup": "Aromanian",
|
||||
"frp": "Arpitan",
|
||||
"as": "Assamese",
|
||||
"ast": "Asturian",
|
||||
"asa": "Asu",
|
||||
"cch": "Atsam",
|
||||
"en_AU": "Australian English",
|
||||
"de_AT": "Austrian German",
|
||||
"av": "Avaric",
|
||||
"ae": "Avestan",
|
||||
"awa": "Awadhi",
|
||||
"ay": "Aymara",
|
||||
"az": "Azerbaijani",
|
||||
"bfq": "Badaga",
|
||||
"ksf": "Bafia",
|
||||
"bfd": "Bafut",
|
||||
"bqi": "Bakhtiari",
|
||||
"ban": "Balinese",
|
||||
"bal": "Baluchi",
|
||||
"bm": "Bambara",
|
||||
"bax": "Bamun",
|
||||
"bjn": "Banjar",
|
||||
"bas": "Basaa",
|
||||
"ba": "Bashkir",
|
||||
"eu": "Basque",
|
||||
"bbc": "Batak Toba",
|
||||
"bar": "Bavarian",
|
||||
"bej": "Beja",
|
||||
"be": "Belarus kasa",
|
||||
"bem": "Bemba",
|
||||
"bez": "Bena",
|
||||
"bn": "Bengali kasa",
|
||||
"bew": "Betawi",
|
||||
"my": "B\u025b\u025bmis kasa",
|
||||
"bho": "Bhojpuri",
|
||||
"bik": "Bikol",
|
||||
"bin": "Bini",
|
||||
"bpy": "Bishnupriya",
|
||||
"bi": "Bislama",
|
||||
"byn": "Blin",
|
||||
"zbl": "Blissymbols",
|
||||
"brx": "Bodo",
|
||||
"en": "Bor\u0254fo",
|
||||
"bs": "Bosnian",
|
||||
"bg": "B\u0254lgeria kasa",
|
||||
"brh": "Brahui",
|
||||
"bra": "Braj",
|
||||
"pt_BR": "Brazilian Portuguese",
|
||||
"br": "Breton",
|
||||
"en_GB": "British English",
|
||||
"bug": "Buginese",
|
||||
"bum": "Bulu",
|
||||
"bua": "Buriat",
|
||||
"cad": "Caddo",
|
||||
"frc": "Cajun French",
|
||||
"en_CA": "Canadian English",
|
||||
"fr_CA": "Canadian French",
|
||||
"yue": "Cantonese",
|
||||
"cps": "Capiznon",
|
||||
"car": "Carib",
|
||||
"ca": "Catalan",
|
||||
"cay": "Cayuga",
|
||||
"ceb": "Cebuano",
|
||||
"tzm": "Central Atlas Tamazight",
|
||||
"dtp": "Central Dusun",
|
||||
"ckb": "Central Kurdish",
|
||||
"esu": "Central Yupik",
|
||||
"shu": "Chadian Arabic",
|
||||
"chg": "Chagatai",
|
||||
"ch": "Chamorro",
|
||||
"ce": "Chechen",
|
||||
"chr": "Cherokee",
|
||||
"chy": "Cheyenne",
|
||||
"chb": "Chibcha",
|
||||
"cgg": "Chiga",
|
||||
"qug": "Chimborazo Highland Quichua",
|
||||
"chn": "Chinook Jargon",
|
||||
"chp": "Chipewyan",
|
||||
"cho": "Choctaw",
|
||||
"cu": "Church Slavic",
|
||||
"chk": "Chuukese",
|
||||
"cv": "Chuvash",
|
||||
"nwc": "Classical Newari",
|
||||
"syc": "Classical Syriac",
|
||||
"ksh": "Colognian",
|
||||
"swb": "Comorian",
|
||||
"swc": "Congo Swahili",
|
||||
"cop": "Coptic",
|
||||
"kw": "Cornish",
|
||||
"co": "Corsican",
|
||||
"cr": "Cree",
|
||||
"mus": "Creek",
|
||||
"crh": "Crimean Turkish",
|
||||
"hr": "Croatian",
|
||||
"dak": "Dakota",
|
||||
"da": "Danish",
|
||||
"dar": "Dargwa",
|
||||
"dzg": "Dazaga",
|
||||
"del": "Delaware",
|
||||
"nl": "D\u025b\u025bkye",
|
||||
"din": "Dinka",
|
||||
"dv": "Divehi",
|
||||
"doi": "Dogri",
|
||||
"dgr": "Dogrib",
|
||||
"dua": "Duala",
|
||||
"dyu": "Dyula",
|
||||
"dz": "Dzongkha",
|
||||
"frs": "Eastern Frisian",
|
||||
"efi": "Efik",
|
||||
"arz": "Egyptian Arabic",
|
||||
"eka": "Ekajuk",
|
||||
"elx": "Elamite",
|
||||
"ebu": "Embu",
|
||||
"egl": "Emilian",
|
||||
"myv": "Erzya",
|
||||
"eo": "Esperanto",
|
||||
"et": "Estonian",
|
||||
"pt_PT": "Portuguese",
|
||||
"es_ES": "Español",
|
||||
"ee": "Ewe",
|
||||
"ewo": "Ewondo",
|
||||
"ext": "Extremaduran",
|
||||
"fan": "Fang",
|
||||
"fat": "Fanti",
|
||||
"fo": "Faroese",
|
||||
"hif": "Fiji Hindi",
|
||||
"fj": "Fijian",
|
||||
"fil": "Filipino",
|
||||
"fi": "Finnish",
|
||||
"nl_BE": "Flemish",
|
||||
"fon": "Fon",
|
||||
"gur": "Frafra",
|
||||
"fr": "Fr\u025bnkye",
|
||||
"fur": "Friulian",
|
||||
"ff": "Fulah",
|
||||
"gaa": "Ga",
|
||||
"gag": "Gagauz",
|
||||
"gl": "Galician",
|
||||
"gan": "Gan Chinese",
|
||||
"lg": "Ganda",
|
||||
"gay": "Gayo",
|
||||
"gba": "Gbaya",
|
||||
"gez": "Geez",
|
||||
"ka": "Georgian",
|
||||
"aln": "Gheg Albanian",
|
||||
"bbj": "Ghomala",
|
||||
"glk": "Gilaki",
|
||||
"gil": "Gilbertese",
|
||||
"gom": "Goan Konkani",
|
||||
"gon": "Gondi",
|
||||
"gor": "Gorontalo",
|
||||
"got": "Gothic",
|
||||
"grb": "Grebo",
|
||||
"el": "Greek kasa",
|
||||
"gn": "Guarani",
|
||||
"gu": "Gujarati",
|
||||
"guz": "Gusii",
|
||||
"gwi": "Gwich\u02bcin",
|
||||
"de": "Gyaaman",
|
||||
"jv": "Gyabanis kasa",
|
||||
"ja": "Gyapan kasa",
|
||||
"hai": "Haida",
|
||||
"ht": "Haitian",
|
||||
"hak": "Hakka Chinese",
|
||||
"hu": "Hangri kasa",
|
||||
"ha": "Hausa",
|
||||
"haw": "Hawaiian",
|
||||
"he": "Hebrew",
|
||||
"hz": "Herero",
|
||||
"hil": "Hiligaynon",
|
||||
"hi": "Hindi",
|
||||
"ho": "Hiri Motu",
|
||||
"hit": "Hittite",
|
||||
"hmn": "Hmong",
|
||||
"hup": "Hupa",
|
||||
"iba": "Iban",
|
||||
"ibb": "Ibibio",
|
||||
"is": "Icelandic",
|
||||
"io": "Ido",
|
||||
"ig": "Igbo",
|
||||
"ilo": "Iloko",
|
||||
"smn": "Inari Sami",
|
||||
"id": "Indonihyia kasa",
|
||||
"izh": "Ingrian",
|
||||
"inh": "Ingush",
|
||||
"ia": "Interlingua",
|
||||
"ie": "Interlingue",
|
||||
"iu": "Inuktitut",
|
||||
"ik": "Inupiaq",
|
||||
"ga": "Irish",
|
||||
"it": "Italy kasa",
|
||||
"jam": "Jamaican Creole English",
|
||||
"kaj": "Jju",
|
||||
"dyo": "Jola-Fonyi",
|
||||
"jrb": "Judeo-Arabic",
|
||||
"jpr": "Judeo-Persian",
|
||||
"jut": "Jutish",
|
||||
"kbd": "Kabardian",
|
||||
"kea": "Kabuverdianu",
|
||||
"kab": "Kabyle",
|
||||
"kac": "Kachin",
|
||||
"kgp": "Kaingang",
|
||||
"kkj": "Kako",
|
||||
"kl": "Kalaallisut",
|
||||
"kln": "Kalenjin",
|
||||
"xal": "Kalmyk",
|
||||
"kam": "Kamba",
|
||||
"km": "Kambodia kasa",
|
||||
"kbl": "Kanembu",
|
||||
"kn": "Kannada",
|
||||
"kr": "Kanuri",
|
||||
"kaa": "Kara-Kalpak",
|
||||
"krc": "Karachay-Balkar",
|
||||
"krl": "Karelian",
|
||||
"ks": "Kashmiri",
|
||||
"csb": "Kashubian",
|
||||
"kaw": "Kawi",
|
||||
"kk": "Kazakh",
|
||||
"ken": "Kenyang",
|
||||
"kha": "Khasi",
|
||||
"kho": "Khotanese",
|
||||
"khw": "Khowar",
|
||||
"ki": "Kikuyu",
|
||||
"kmb": "Kimbundu",
|
||||
"krj": "Kinaray-a",
|
||||
"kiu": "Kirmanjki",
|
||||
"tlh": "Klingon",
|
||||
"bkm": "Kom",
|
||||
"kv": "Komi",
|
||||
"koi": "Komi-Permyak",
|
||||
"kg": "Kongo",
|
||||
"kok": "Konkani",
|
||||
"ko": "Korea kasa",
|
||||
"kfo": "Koro",
|
||||
"kos": "Kosraean",
|
||||
"avk": "Kotava",
|
||||
"khq": "Koyra Chiini",
|
||||
"ses": "Koyraboro Senni",
|
||||
"kpe": "Kpelle",
|
||||
"kri": "Krio",
|
||||
"kj": "Kuanyama",
|
||||
"kum": "Kumyk",
|
||||
"ku": "Kurdish",
|
||||
"kru": "Kurukh",
|
||||
"kut": "Kutenai",
|
||||
"nmg": "Kwasio",
|
||||
"zh": "Kyaena kasa",
|
||||
"cs": "Ky\u025bk kasa",
|
||||
"ky": "Kyrgyz",
|
||||
"quc": "K\u02bciche\u02bc",
|
||||
"lad": "Ladino",
|
||||
"lah": "Lahnda",
|
||||
"lkt": "Lakota",
|
||||
"lam": "Lamba",
|
||||
"lag": "Langi",
|
||||
"lo": "Lao",
|
||||
"ltg": "Latgalian",
|
||||
"la": "Latin",
|
||||
"es_419": "Latin American Spanish",
|
||||
"lv": "Latvian",
|
||||
"lzz": "Laz",
|
||||
"lez": "Lezghian",
|
||||
"lij": "Ligurian",
|
||||
"li": "Limburgish",
|
||||
"ln": "Lingala",
|
||||
"lfn": "Lingua Franca Nova",
|
||||
"lzh": "Literary Chinese",
|
||||
"lt": "Lithuanian",
|
||||
"liv": "Livonian",
|
||||
"jbo": "Lojban",
|
||||
"lmo": "Lombard",
|
||||
"nds": "Low German",
|
||||
"sli": "Lower Silesian",
|
||||
"dsb": "Lower Sorbian",
|
||||
"loz": "Lozi",
|
||||
"lu": "Luba-Katanga",
|
||||
"lua": "Luba-Lulua",
|
||||
"lui": "Luiseno",
|
||||
"smj": "Lule Sami",
|
||||
"lun": "Lunda",
|
||||
"luo": "Luo",
|
||||
"lb": "Luxembourgish",
|
||||
"luy": "Luyia",
|
||||
"mde": "Maba",
|
||||
"mk": "Macedonian",
|
||||
"jmc": "Machame",
|
||||
"mad": "Madurese",
|
||||
"maf": "Mafa",
|
||||
"mag": "Magahi",
|
||||
"vmf": "Main-Franconian",
|
||||
"mai": "Maithili",
|
||||
"mak": "Makasar",
|
||||
"mgh": "Makhuwa-Meetto",
|
||||
"kde": "Makonde",
|
||||
"mg": "Malagasy",
|
||||
"ms": "Malay kasa",
|
||||
"ml": "Malayalam",
|
||||
"mt": "Maltese",
|
||||
"mnc": "Manchu",
|
||||
"mdr": "Mandar",
|
||||
"man": "Mandingo",
|
||||
"mni": "Manipuri",
|
||||
"gv": "Manx",
|
||||
"mi": "Maori",
|
||||
"arn": "Mapuche",
|
||||
"mr": "Marathi",
|
||||
"chm": "Mari",
|
||||
"mh": "Marshallese",
|
||||
"mwr": "Marwari",
|
||||
"mas": "Masai",
|
||||
"mzn": "Mazanderani",
|
||||
"byv": "Medumba",
|
||||
"men": "Mende",
|
||||
"mwv": "Mentawai",
|
||||
"mer": "Meru",
|
||||
"mgo": "Meta\u02bc",
|
||||
"es_MX": "Mexican Spanish",
|
||||
"mic": "Micmac",
|
||||
"dum": "Middle Dutch",
|
||||
"enm": "Middle English",
|
||||
"frm": "Middle French",
|
||||
"gmh": "Middle High German",
|
||||
"mga": "Middle Irish",
|
||||
"nan": "Min Nan Chinese",
|
||||
"min": "Minangkabau",
|
||||
"xmf": "Mingrelian",
|
||||
"mwl": "Mirandese",
|
||||
"lus": "Mizo",
|
||||
"ar_001": "Modern Standard Arabic",
|
||||
"moh": "Mohawk",
|
||||
"mdf": "Moksha",
|
||||
"ro_MD": "Moldavian",
|
||||
"lol": "Mongo",
|
||||
"mn": "Mongolian",
|
||||
"mfe": "Morisyen",
|
||||
"ary": "Moroccan Arabic",
|
||||
"mos": "Mossi",
|
||||
"mul": "Multiple Languages",
|
||||
"mua": "Mundang",
|
||||
"ttt": "Muslim Tat",
|
||||
"mye": "Myene",
|
||||
"naq": "Nama",
|
||||
"na": "Nauru",
|
||||
"nv": "Navajo",
|
||||
"ng": "Ndonga",
|
||||
"nap": "Neapolitan",
|
||||
"new": "Newari",
|
||||
"ne": "N\u025bpal kasa",
|
||||
"sba": "Ngambay",
|
||||
"nnh": "Ngiemboon",
|
||||
"jgo": "Ngomba",
|
||||
"yrl": "Nheengatu",
|
||||
"nia": "Nias",
|
||||
"niu": "Niuean",
|
||||
"zxx": "No linguistic content",
|
||||
"nog": "Nogai",
|
||||
"nd": "North Ndebele",
|
||||
"frr": "Northern Frisian",
|
||||
"se": "Northern Sami",
|
||||
"nso": "Northern Sotho",
|
||||
"no": "Norwegian",
|
||||
"nb": "Norwegian Bokm\u00e5l",
|
||||
"nn": "Norwegian Nynorsk",
|
||||
"nov": "Novial",
|
||||
"nus": "Nuer",
|
||||
"nym": "Nyamwezi",
|
||||
"ny": "Nyanja",
|
||||
"nyn": "Nyankole",
|
||||
"tog": "Nyasa Tonga",
|
||||
"nyo": "Nyoro",
|
||||
"nzi": "Nzima",
|
||||
"nqo": "N\u02bcKo",
|
||||
"oc": "Occitan",
|
||||
"oj": "Ojibwa",
|
||||
"ang": "Old English",
|
||||
"fro": "Old French",
|
||||
"goh": "Old High German",
|
||||
"sga": "Old Irish",
|
||||
"non": "Old Norse",
|
||||
"peo": "Old Persian",
|
||||
"pro": "Old Proven\u00e7al",
|
||||
"or": "Oriya",
|
||||
"om": "Oromo",
|
||||
"osa": "Osage",
|
||||
"os": "Ossetic",
|
||||
"ota": "Ottoman Turkish",
|
||||
"pal": "Pahlavi",
|
||||
"pfl": "Palatine German",
|
||||
"pau": "Palauan",
|
||||
"pi": "Pali",
|
||||
"pam": "Pampanga",
|
||||
"pag": "Pangasinan",
|
||||
"pap": "Papiamento",
|
||||
"ps": "Pashto",
|
||||
"pdc": "Pennsylvania German",
|
||||
"fa": "P\u025b\u025bhyia kasa",
|
||||
"phn": "Phoenician",
|
||||
"pcd": "Picard",
|
||||
"pms": "Piedmontese",
|
||||
"pdt": "Plautdietsch",
|
||||
"pon": "Pohnpeian",
|
||||
"pnt": "Pontic",
|
||||
"pl": "P\u0254land kasa",
|
||||
"pt": "P\u0254\u0254tugal kasa",
|
||||
"prg": "Prussian",
|
||||
"pa": "Pungyabi kasa",
|
||||
"qu": "Quechua",
|
||||
"ru": "Rahyia kasa",
|
||||
"raj": "Rajasthani",
|
||||
"rap": "Rapanui",
|
||||
"rar": "Rarotongan",
|
||||
"rw": "Rewanda kasa",
|
||||
"rif": "Riffian",
|
||||
"rgn": "Romagnol",
|
||||
"rm": "Romansh",
|
||||
"rom": "Romany",
|
||||
"rof": "Rombo",
|
||||
"ro": "Romenia kasa",
|
||||
"root": "Root",
|
||||
"rtm": "Rotuman",
|
||||
"rug": "Roviana",
|
||||
"rn": "Rundi",
|
||||
"rue": "Rusyn",
|
||||
"rwk": "Rwa",
|
||||
"ssy": "Saho",
|
||||
"sah": "Sakha",
|
||||
"sam": "Samaritan Aramaic",
|
||||
"saq": "Samburu",
|
||||
"sm": "Samoan",
|
||||
"sgs": "Samogitian",
|
||||
"sad": "Sandawe",
|
||||
"sg": "Sango",
|
||||
"sbp": "Sangu",
|
||||
"sa": "Sanskrit",
|
||||
"sat": "Santali",
|
||||
"sc": "Sardinian",
|
||||
"sas": "Sasak",
|
||||
"sdc": "Sassarese Sardinian",
|
||||
"stq": "Saterland Frisian",
|
||||
"saz": "Saurashtra",
|
||||
"sco": "Scots",
|
||||
"gd": "Scottish Gaelic",
|
||||
"sly": "Selayar",
|
||||
"sel": "Selkup",
|
||||
"seh": "Sena",
|
||||
"see": "Seneca",
|
||||
"sr": "Serbian",
|
||||
"sh": "Serbo-Croatian",
|
||||
"srr": "Serer",
|
||||
"sei": "Seri",
|
||||
"ksb": "Shambala",
|
||||
"shn": "Shan",
|
||||
"sn": "Shona",
|
||||
"ii": "Sichuan Yi",
|
||||
"scn": "Sicilian",
|
||||
"sid": "Sidamo",
|
||||
"bla": "Siksika",
|
||||
"szl": "Silesian",
|
||||
"zh_Hans": "Simplified Chinese",
|
||||
"sd": "Sindhi",
|
||||
"si": "Sinhala",
|
||||
"sms": "Skolt Sami",
|
||||
"den": "Slave",
|
||||
"sk": "Slovak",
|
||||
"sl": "Slovenian",
|
||||
"xog": "Soga",
|
||||
"sog": "Sogdien",
|
||||
"so": "Somalia kasa",
|
||||
"snk": "Soninke",
|
||||
"azb": "South Azerbaijani",
|
||||
"nr": "South Ndebele",
|
||||
"alt": "Southern Altai",
|
||||
"sma": "Southern Sami",
|
||||
"st": "Southern Sotho",
|
||||
"es": "Spain kasa",
|
||||
"srn": "Sranan Tongo",
|
||||
"zgh": "Standard Moroccan Tamazight",
|
||||
"suk": "Sukuma",
|
||||
"sux": "Sumerian",
|
||||
"su": "Sundanese",
|
||||
"sus": "Susu",
|
||||
"sw": "Swahili",
|
||||
"ss": "Swati",
|
||||
"sv": "Sweden kasa",
|
||||
"fr_CH": "Swiss French",
|
||||
"gsw": "Swiss German",
|
||||
"de_CH": "Swiss High German",
|
||||
"syr": "Syriac",
|
||||
"shi": "Tachelhit",
|
||||
"th": "Taeland kasa",
|
||||
"tl": "Tagalog",
|
||||
"ty": "Tahitian",
|
||||
"dav": "Taita",
|
||||
"tg": "Tajik",
|
||||
"tly": "Talysh",
|
||||
"tmh": "Tamashek",
|
||||
"ta": "Tamil kasa",
|
||||
"trv": "Taroko",
|
||||
"twq": "Tasawaq",
|
||||
"tt": "Tatar",
|
||||
"te": "Telugu",
|
||||
"ter": "Tereno",
|
||||
"teo": "Teso",
|
||||
"tet": "Tetum",
|
||||
"tr": "T\u025b\u025bki kasa",
|
||||
"bo": "Tibetan",
|
||||
"tig": "Tigre",
|
||||
"ti": "Tigrinya",
|
||||
"tem": "Timne",
|
||||
"tiv": "Tiv",
|
||||
"tli": "Tlingit",
|
||||
"tpi": "Tok Pisin",
|
||||
"tkl": "Tokelau",
|
||||
"to": "Tongan",
|
||||
"fit": "Tornedalen Finnish",
|
||||
"zh_Hant": "Traditional Chinese",
|
||||
"tkr": "Tsakhur",
|
||||
"tsd": "Tsakonian",
|
||||
"tsi": "Tsimshian",
|
||||
"ts": "Tsonga",
|
||||
"tn": "Tswana",
|
||||
"tcy": "Tulu",
|
||||
"tum": "Tumbuka",
|
||||
"aeb": "Tunisian Arabic",
|
||||
"tk": "Turkmen",
|
||||
"tru": "Turoyo",
|
||||
"tvl": "Tuvalu",
|
||||
"tyv": "Tuvinian",
|
||||
"tw": "Twi",
|
||||
"kcg": "Tyap",
|
||||
"udm": "Udmurt",
|
||||
"uga": "Ugaritic",
|
||||
"uk": "Ukren kasa",
|
||||
"umb": "Umbundu",
|
||||
"und": "Unknown Language",
|
||||
"hsb": "Upper Sorbian",
|
||||
"ur": "Urdu kasa",
|
||||
"ug": "Uyghur",
|
||||
"uz": "Uzbek",
|
||||
"vai": "Vai",
|
||||
"ve": "Venda",
|
||||
"vec": "Venetian",
|
||||
"vep": "Veps",
|
||||
"vi": "Vi\u025btnam kasa",
|
||||
"vo": "Volap\u00fck",
|
||||
"vro": "V\u00f5ro",
|
||||
"vot": "Votic",
|
||||
"vun": "Vunjo",
|
||||
"wa": "Walloon",
|
||||
"wae": "Walser",
|
||||
"war": "Waray",
|
||||
"wbp": "Warlpiri",
|
||||
"was": "Washo",
|
||||
"guc": "Wayuu",
|
||||
"cy": "Welsh",
|
||||
"vls": "West Flemish",
|
||||
"fy": "Western Frisian",
|
||||
"mrj": "Western Mari",
|
||||
"wal": "Wolaytta",
|
||||
"wo": "Wolof",
|
||||
"wuu": "Wu Chinese",
|
||||
"xh": "Xhosa",
|
||||
"hsn": "Xiang Chinese",
|
||||
"yav": "Yangben",
|
||||
"yao": "Yao",
|
||||
"yap": "Yapese",
|
||||
"ybb": "Yemba",
|
||||
"yi": "Yiddish",
|
||||
"yo": "Yoruba",
|
||||
"zap": "Zapotec",
|
||||
"dje": "Zarma",
|
||||
"zza": "Zaza",
|
||||
"zea": "Zeelandic",
|
||||
"zen": "Zenaga",
|
||||
"za": "Zhuang",
|
||||
"gbz": "Zoroastrian Dari",
|
||||
"zu": "Zulu",
|
||||
"zun": "Zuni"
|
||||
"en": "English",
|
||||
"es": "Español",
|
||||
"fr": "Français",
|
||||
"de": "Deutsch",
|
||||
"it": "Italiano",
|
||||
"pt": "Português"
|
||||
}
|
6
packages/app/config/randomErrorImages.json
Normal file
6
packages/app/config/randomErrorImages.json
Normal file
@ -0,0 +1,6 @@
|
||||
[
|
||||
"https://randomfox.ca/images/121.jpg",
|
||||
"https://storage.ragestudio.net/comty-cdn/627d4b628cf4b82edd0864ff/b8147f7038c1dd698746f3d45d6812ffdd3d3836c5edb121518d301aa88f4cee",
|
||||
"https://storage.ragestudio.net/comty-cdn/627d4b628cf4b82edd0864ff/cdcf52344f121f097ac3d363cd9ccc7169b2b7b419a914bd74786940f357bc3a",
|
||||
"https://storage.ragestudio.net/comty-cdn/627d4b628cf4b82edd0864ff/e90ee040c9531a39397c7af7fb0f3422255beb3115a9c505649faa0edaeb455a"
|
||||
]
|
@ -14,6 +14,11 @@ export default [
|
||||
useLayout: "default",
|
||||
public: true,
|
||||
},
|
||||
{
|
||||
path: "/tv/*",
|
||||
useLayout: "default",
|
||||
centeredContent: false,
|
||||
},
|
||||
{
|
||||
path: "/featured-event/*",
|
||||
useLayout: "default",
|
||||
@ -31,7 +36,7 @@ export default [
|
||||
{
|
||||
path: "/music/*",
|
||||
useLayout: "default",
|
||||
centeredContent: true,
|
||||
centeredContent: false,
|
||||
},
|
||||
{
|
||||
path: "/nfc/*",
|
||||
|
@ -14,4 +14,10 @@ export default defineConfig([
|
||||
languageOptions: { globals: globals.browser },
|
||||
},
|
||||
pluginReact.configs.flat.recommended,
|
||||
{
|
||||
rules: {
|
||||
"react/jsx-uses-react": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
@ -1,91 +1,91 @@
|
||||
{
|
||||
"name": "@comty/app",
|
||||
"version": "1.43.0@alpha",
|
||||
"license": "ComtyLicense",
|
||||
"main": "electron/main",
|
||||
"type": "module",
|
||||
"author": "RageStudio",
|
||||
"description": "A prototype of a social network.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"release": "node ./scripts/release.js",
|
||||
"postinstall": "./scripts/postinstall.sh",
|
||||
"eslint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@ffmpeg/ffmpeg": "^0.12.10",
|
||||
"@ffmpeg/util": "^0.12.1",
|
||||
"@mui/material": "^5.11.9",
|
||||
"@ragestudio/cordova-nfc": "^1.2.0",
|
||||
"@ragestudio/vessel": "^0.20.0",
|
||||
"@sentry/browser": "^7.64.0",
|
||||
"@tauri-apps/api": "^1.5.4",
|
||||
"@tsmx/human-readable": "^1.0.7",
|
||||
"antd": "^5.20.6",
|
||||
"axios": "^1.7.7",
|
||||
"bear-react-carousel": "^4.0.10-alpha.0",
|
||||
"classnames": "2.3.1",
|
||||
"comty.js": "^0.67.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"fast-average-color": "^9.2.0",
|
||||
"fuse.js": "6.5.3",
|
||||
"hls.js": "^1.5.17",
|
||||
"howler": "2.2.3",
|
||||
"i18next": "21.6.6",
|
||||
"js-cookie": "3.0.1",
|
||||
"jsmediatags": "^3.9.7",
|
||||
"lottie-react": "^2.4.0",
|
||||
"luxon": "^3.0.4",
|
||||
"mime": "^3.0.0",
|
||||
"moment": "2.29.4",
|
||||
"motion": "^12.4.2",
|
||||
"music-metadata": "^11.2.1",
|
||||
"plyr": "^3.7.8",
|
||||
"prop-types": "^15.8.1",
|
||||
"qs": "^6.14.0",
|
||||
"react": "18.3.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-color": "2.19.3",
|
||||
"react-countup": "^6.4.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-fast-marquee": "^1.3.5",
|
||||
"react-i18next": "11.15.3",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-lazy-load-image-component": "^1.5.4",
|
||||
"react-markdown": "^8.0.3",
|
||||
"react-modal-image": "^2.6.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-rnd": "^10.4.14",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-useanimations": "^2.10.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"rxjs": "^7.5.5",
|
||||
"shaka-player": "^4.14.12",
|
||||
"store": "^2.0.12",
|
||||
"swapy": "^1.0.5",
|
||||
"ua-parser-js": "^1.0.36",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^6.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.26.0",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"7zip-min": "1.4.3",
|
||||
"dotenv": "16.0.3",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"form-data": "^4.0.0",
|
||||
"globals": "^16.1.0"
|
||||
}
|
||||
"name": "@comty/app",
|
||||
"version": "1.44.0@alpha",
|
||||
"license": "ComtyLicense",
|
||||
"main": "electron/main",
|
||||
"type": "module",
|
||||
"author": "RageStudio",
|
||||
"description": "A prototype of a social network.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"release": "node ./scripts/release.js",
|
||||
"postinstall": "./scripts/postinstall.sh",
|
||||
"eslint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.4.0",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@ffmpeg/ffmpeg": "^0.12.10",
|
||||
"@ffmpeg/util": "^0.12.1",
|
||||
"@mui/material": "^5.11.9",
|
||||
"@ragestudio/cordova-nfc": "^1.2.0",
|
||||
"@ragestudio/vessel": "^0.20.0",
|
||||
"@sentry/browser": "^7.64.0",
|
||||
"@tauri-apps/api": "^1.5.4",
|
||||
"@tsmx/human-readable": "^1.0.7",
|
||||
"antd": "^5.20.6",
|
||||
"axios": "^1.7.7",
|
||||
"bear-react-carousel": "^4.0.10-alpha.0",
|
||||
"classnames": "2.3.1",
|
||||
"comty.js": "^0.68.0",
|
||||
"d3": "^7.9.0",
|
||||
"dashjs": "^5.0.3",
|
||||
"dompurify": "^3.0.0",
|
||||
"fast-average-color": "^9.2.0",
|
||||
"fuse.js": "6.5.3",
|
||||
"hls.js": "^1.5.17",
|
||||
"howler": "2.2.3",
|
||||
"i18next": "21.6.6",
|
||||
"js-cookie": "3.0.1",
|
||||
"jsmediatags": "^3.9.7",
|
||||
"lottie-react": "^2.4.0",
|
||||
"luxon": "^3.0.4",
|
||||
"mime": "^3.0.0",
|
||||
"moment": "2.29.4",
|
||||
"motion": "^12.4.2",
|
||||
"music-metadata": "^11.2.1",
|
||||
"plyr": "^3.7.8",
|
||||
"prop-types": "^15.8.1",
|
||||
"qs": "^6.14.0",
|
||||
"react": "18.3.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-color": "2.19.3",
|
||||
"react-countup": "^6.4.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-fast-marquee": "^1.3.5",
|
||||
"react-i18next": "11.15.3",
|
||||
"react-icons": "^5.4.0",
|
||||
"react-lazy-load-image-component": "^1.5.4",
|
||||
"react-markdown": "^8.0.3",
|
||||
"react-modal-image": "^2.6.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-rnd": "^10.4.14",
|
||||
"react-router": "^7.6.2",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-useanimations": "^2.10.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"rxjs": "^7.5.5",
|
||||
"store": "^2.0.12",
|
||||
"swapy": "^1.0.5",
|
||||
"ua-parser-js": "^1.0.36",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^6.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.26.0",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"7zip-min": "1.4.3",
|
||||
"dotenv": "16.0.3",
|
||||
"eslint": "^9.26.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"form-data": "^4.0.0",
|
||||
"globals": "^16.1.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,10 @@
|
||||
import React from "react"
|
||||
import { Runtime } from "@ragestudio/vessel"
|
||||
import * as Router from "@ragestudio/vessel/router"
|
||||
import routesDeclarations from "@config/routes"
|
||||
|
||||
import onPageMount from "@hooks/onPageMount"
|
||||
|
||||
import { Helmet } from "react-helmet"
|
||||
import * as Sentry from "@sentry/browser"
|
||||
import { invoke } from "@tauri-apps/api/tauri"
|
||||
@ -14,7 +19,6 @@ import DesktopTopBar from "@components/DesktopTopBar"
|
||||
import { ThemeProvider } from "@cores/style/style.core.jsx"
|
||||
|
||||
import Layout from "./layout"
|
||||
import * as Router from "./router"
|
||||
|
||||
import StaticMethods from "./statics/methods"
|
||||
import StaticEvents from "./statics/events"
|
||||
@ -117,19 +121,19 @@ class ComtyApp extends React.Component {
|
||||
/>
|
||||
<meta property="og:title" content={config.app.siteName} />
|
||||
</Helmet>
|
||||
<Router.InternalRouter>
|
||||
<ThemeProvider>
|
||||
{window.__TAURI__ && <DesktopTopBar />}
|
||||
<Layout
|
||||
user={this.auth.user}
|
||||
staticRenders={ComtyApp.staticRenders}
|
||||
>
|
||||
{this.state.firstInitialized && (
|
||||
<Router.PageRender />
|
||||
)}
|
||||
</Layout>
|
||||
</ThemeProvider>
|
||||
</Router.InternalRouter>
|
||||
|
||||
<ThemeProvider>
|
||||
{window.__TAURI__ && <DesktopTopBar />}
|
||||
<Layout staticRenders={ComtyApp.staticRenders}>
|
||||
{this.state.firstInitialized && (
|
||||
<Router.Render
|
||||
declarations={routesDeclarations}
|
||||
staticRenders={ComtyApp.staticRenders}
|
||||
onPageMount={onPageMount}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
</ThemeProvider>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
@ -70,6 +70,11 @@ export default class AuthManager {
|
||||
},
|
||||
}
|
||||
|
||||
handleUserDataUpdate = (data) => {
|
||||
this.state.user = data
|
||||
app.eventBus.emit("self:user:update", data)
|
||||
}
|
||||
|
||||
initialize = async () => {
|
||||
const token = await SessionModel.token
|
||||
|
||||
@ -87,6 +92,8 @@ export default class AuthManager {
|
||||
|
||||
app.userData = user
|
||||
this.state.user = user
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
flush = async () => {
|
||||
|
154
packages/app/src/components/ErrorCatcher/index.jsx
Normal file
154
packages/app/src/components/ErrorCatcher/index.jsx
Normal file
@ -0,0 +1,154 @@
|
||||
import React from "react"
|
||||
import { useRouteError } from "react-router"
|
||||
import { Flex, Button } from "antd"
|
||||
import Image from "@components/Image"
|
||||
|
||||
import excuses from "@config/excuses"
|
||||
import randomErrorImages from "@config/randomErrorImages"
|
||||
|
||||
const detailsPreStyle = {
|
||||
overflow: "hidden",
|
||||
wordBreak: "break-word",
|
||||
whiteSpace: "pre-wrap",
|
||||
userSelect: "text",
|
||||
backgroundColor: "var(--background-color-accent)",
|
||||
padding: "7px",
|
||||
borderRadius: "5px",
|
||||
}
|
||||
|
||||
const PageErrorBoundary = (props) => {
|
||||
const error = useRouteError()
|
||||
const errorId = React.useCallback(
|
||||
() => `error_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
[],
|
||||
)
|
||||
const errorInfo = error?.errorInfo
|
||||
|
||||
const handleRetry = () => {}
|
||||
|
||||
const handleReload = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const handleGoHome = () => {
|
||||
if (window.app?.location?.push) {
|
||||
window.app.location.push("/")
|
||||
} else {
|
||||
window.location.href = "/"
|
||||
}
|
||||
}
|
||||
|
||||
const copyErrorDetails = () => {
|
||||
const errorDetails = {
|
||||
errorId: errorId,
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
componentStack: errorInfo?.componentStack,
|
||||
path: props.path,
|
||||
url: window.location.href,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
}
|
||||
|
||||
const errorText = JSON.stringify(errorDetails, null, 2)
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(errorText).then(() => {
|
||||
app.message.success("Details copied to clipboard")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
gap={20}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Flex horizontal gap={20} align="center">
|
||||
<Image
|
||||
src={
|
||||
randomErrorImages[
|
||||
Math.floor(Math.random() * randomErrorImages.length)
|
||||
]
|
||||
}
|
||||
style={{
|
||||
width: 128,
|
||||
height: 128,
|
||||
objectFit: "cover",
|
||||
overflowClipMargin: "unset",
|
||||
}}
|
||||
wrapperProps={{
|
||||
style: {
|
||||
borderRadius: "12px",
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Flex vertical gap={10}>
|
||||
<h2 style={{ margin: 0, fontSize: "1.2rem" }}>
|
||||
Something went wrong
|
||||
</h2>
|
||||
<span>
|
||||
<strong>Path:</strong> {props.path || "Unknown"}
|
||||
</span>
|
||||
<span style={{ fontSize: "0.9rem", opacity: 0.8 }}>
|
||||
ID: {errorId}
|
||||
</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Flex vertical>
|
||||
<strong>Message:</strong>
|
||||
<pre style={detailsPreStyle}>
|
||||
{error?.message || "Unknown error"}
|
||||
</pre>
|
||||
</Flex>
|
||||
|
||||
<Flex vertical gap={10}>
|
||||
<details>
|
||||
<summary style={{ cursor: "pointer", fontWeight: "bold" }}>
|
||||
Error Stack
|
||||
</summary>
|
||||
<pre style={detailsPreStyle}>
|
||||
{error?.stack || "No stack trace available"}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary style={{ cursor: "pointer", fontWeight: "bold" }}>
|
||||
Component Stack
|
||||
</summary>
|
||||
<pre style={detailsPreStyle}>
|
||||
{errorInfo?.componentStack ||
|
||||
"No component stack available"}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary style={{ cursor: "pointer", fontWeight: "bold" }}>
|
||||
Excuse
|
||||
</summary>
|
||||
<pre style={detailsPreStyle}>
|
||||
{excuses[Math.floor(Math.random() * excuses.length)]}
|
||||
</pre>
|
||||
</details>
|
||||
</Flex>
|
||||
|
||||
<Flex horizontal gap={10}>
|
||||
<Button onClick={handleRetry}>🔄 Retry</Button>
|
||||
<Button onClick={handleReload}>🔄 Reload Page</Button>
|
||||
<Button onClick={copyErrorDetails}>
|
||||
📋 Copy Error Details
|
||||
</Button>
|
||||
<Button onClick={handleGoHome}>🏠 Go Home</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageErrorBoundary
|
@ -1,26 +1,28 @@
|
||||
import React from "react"
|
||||
import { Button } from "antd"
|
||||
import classnames from "classnames"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export default (props) => {
|
||||
return <div className="followButton">
|
||||
<div className="counter">
|
||||
{props.count}
|
||||
{props.self && " Followers"}
|
||||
</div>
|
||||
{
|
||||
!props.self && <Button
|
||||
type="ghost"
|
||||
onClick={props.onClick}
|
||||
className={classnames(
|
||||
"btn",
|
||||
{ ["followed"]: props.followed }
|
||||
)}
|
||||
>
|
||||
<span>{props.followed ? "Following" : "Follow"}</span>
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
const FollowButton = (props) => {
|
||||
return (
|
||||
<div className="followButton">
|
||||
<div className="counter">
|
||||
{props.count}
|
||||
{props.self && " Followers"}
|
||||
</div>
|
||||
{!props.self && (
|
||||
<Button
|
||||
type="ghost"
|
||||
onClick={props.onClick}
|
||||
className={classnames("btn", {
|
||||
["followed"]: props.followed,
|
||||
})}
|
||||
>
|
||||
<span>{props.followed ? "Following" : "Follow"}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FollowButton
|
||||
|
@ -1,101 +1,98 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import { Icons } from "@components/Icons"
|
||||
import LoadMore from "@components/LoadMore"
|
||||
import UserPreview from "@components/UserPreview"
|
||||
|
||||
import FollowsModel from "@models/follows"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const FollowerItem = ({
|
||||
follower,
|
||||
onClick,
|
||||
index
|
||||
}) => {
|
||||
return <div
|
||||
className="follower"
|
||||
onClick={onClick}
|
||||
key={index}
|
||||
>
|
||||
<div className="avatar">
|
||||
<antd.Avatar shape="square" src={follower.avatar} />
|
||||
</div>
|
||||
<div className="names">
|
||||
<div>
|
||||
<h2>
|
||||
{follower.fullName ?? follower.username}
|
||||
</h2>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
@{follower.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const FollowerItem = React.memo(({ data }) => {
|
||||
return <UserPreview user={data} />
|
||||
})
|
||||
|
||||
FollowerItem.displayName = "FollowerItem"
|
||||
|
||||
const FollowersList = (props) => {
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [followers, setFollowers] = React.useState(props.followers ?? [])
|
||||
const [hasMore, setHasMore] = React.useState(true)
|
||||
|
||||
const page = React.useRef(0)
|
||||
const userId = React.useRef(props.user_id)
|
||||
|
||||
const loadFollowers = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
||||
console.log(
|
||||
`Loading Followers for [${userId.current}] page [${page.current}]`,
|
||||
)
|
||||
|
||||
const followers = await FollowsModel.getFollowers(userId.current, {
|
||||
fetchData: true,
|
||||
limit: 10,
|
||||
page: page.current,
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
app.message.error("Failed to fetch followers")
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (followers) {
|
||||
console.log(`Loaded Followers :`, followers)
|
||||
setFollowers((prev) => {
|
||||
return [...prev, ...followers.items]
|
||||
})
|
||||
|
||||
if (followers.has_more) {
|
||||
setHasMore(true)
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
}
|
||||
}, [userId.current])
|
||||
|
||||
const onLoadMore = React.useCallback(() => {
|
||||
page.current += 1
|
||||
loadFollowers()
|
||||
}, [userId.current])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!props.followers) {
|
||||
if (props.user_id) {
|
||||
userId.current = props.user_id
|
||||
page.current = 0
|
||||
setFollowers([])
|
||||
setHasMore(true)
|
||||
loadFollowers()
|
||||
}
|
||||
}
|
||||
}, [props.user_id])
|
||||
|
||||
if (!loading && followers.length === 0) {
|
||||
return (
|
||||
<antd.Result icon={<Icons.FiUserX style={{ fontSize: "50px" }} />}>
|
||||
<h2>It's seems this user has no followers, yet.</h2>
|
||||
<h3>Maybe you can help them out?</h3>
|
||||
</antd.Result>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadMore
|
||||
className="followersList"
|
||||
onBottom={onLoadMore}
|
||||
hasMore={hasMore}
|
||||
>
|
||||
{followers.map((data) => {
|
||||
return <FollowerItem key={data._id} data={data} />
|
||||
})}
|
||||
</LoadMore>
|
||||
)
|
||||
}
|
||||
|
||||
export default (props) => {
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [followers, setFollowers] = React.useState(props.followers ?? [])
|
||||
|
||||
const goToProfile = (username) => {
|
||||
app.navigation.goToAccount(username)
|
||||
}
|
||||
|
||||
const loadFollowers = async () => {
|
||||
setLoading(true)
|
||||
|
||||
console.log(`Loading Followers for [${props.user_id}]...`)
|
||||
|
||||
const followers = await FollowsModel.getFollowers(props.user_id, true).catch((err) => {
|
||||
console.error(err)
|
||||
app.message.error("Failed to fetch followers")
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
|
||||
if (followers) {
|
||||
console.log(`Loaded Followers: [${followers.length}] >`, followers)
|
||||
setFollowers(followers)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!props.followers) {
|
||||
if (props.user_id) {
|
||||
loadFollowers()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
|
||||
if (followers.length === 0) {
|
||||
return <antd.Result
|
||||
icon={<Icons.FiUserX style={{ fontSize: "50px" }} />}
|
||||
>
|
||||
<h2>
|
||||
It's seems this user has no followers, yet.
|
||||
</h2>
|
||||
<h3>
|
||||
Maybe you can help them out?
|
||||
</h3>
|
||||
</antd.Result>
|
||||
}
|
||||
|
||||
return <div className="followersList">
|
||||
{
|
||||
followers.map((follower, index) => {
|
||||
return <FollowerItem
|
||||
index={index}
|
||||
follower={follower}
|
||||
onClick={() => goToProfile(follower.username)}
|
||||
/>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
export default FollowersList
|
||||
|
@ -33,37 +33,18 @@
|
||||
}
|
||||
|
||||
.followersList {
|
||||
.follower {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
.userPreview {
|
||||
background-color: var(--background-color-primary);
|
||||
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
>div {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.names {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.follower:last-child {
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
@ -102,6 +102,9 @@ export default class LiveChat extends React.Component {
|
||||
}
|
||||
|
||||
leaveSocketRoom = () => {
|
||||
if (!this.socket) {
|
||||
return false
|
||||
}
|
||||
if (this.state.connectionEnd) {
|
||||
return false
|
||||
}
|
||||
|
@ -3,59 +3,65 @@ import classnames from "classnames"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export default React.forwardRef((props, ref) => {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
hasMore,
|
||||
loadingComponent,
|
||||
noResultComponent,
|
||||
contentProps = {},
|
||||
} = props
|
||||
const LoadMore = React.forwardRef((props, ref) => {
|
||||
const {
|
||||
className,
|
||||
children,
|
||||
hasMore = false,
|
||||
loadingComponent,
|
||||
contentProps = {},
|
||||
} = props
|
||||
|
||||
let observer = null
|
||||
const nodeRef = React.useRef(null)
|
||||
|
||||
const insideViewportCb = (entries) => {
|
||||
const { fetching, onBottom } = props
|
||||
let observer = null
|
||||
|
||||
entries.forEach(element => {
|
||||
if (element.intersectionRatio > 0 && !fetching) {
|
||||
onBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
const insideViewportCb = (entries) => {
|
||||
const { fetching, onBottom, hasMore } = props
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const node = document.getElementById("bottom")
|
||||
if (!hasMore) {
|
||||
return false
|
||||
}
|
||||
|
||||
observer = new IntersectionObserver(insideViewportCb)
|
||||
observer.observe(node)
|
||||
} catch (err) {
|
||||
console.log("err in finding node", err)
|
||||
}
|
||||
entries.forEach((element) => {
|
||||
if (element.intersectionRatio > 0 && !fetching) {
|
||||
onBottom()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
}, [])
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
observer = new IntersectionObserver(insideViewportCb)
|
||||
observer.observe(nodeRef.current)
|
||||
} catch (err) {
|
||||
console.log("err in finding node", err)
|
||||
}
|
||||
|
||||
return <div
|
||||
ref={ref}
|
||||
className={classnames(className)}
|
||||
{...contentProps}
|
||||
>
|
||||
{children}
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
<div style={{ clear: "both" }} />
|
||||
return (
|
||||
<div ref={ref} className={classnames(className)} {...contentProps}>
|
||||
{children}
|
||||
|
||||
<div
|
||||
id="bottom"
|
||||
className="bottom"
|
||||
style={{ display: hasMore ? "block" : "none" }}
|
||||
>
|
||||
{loadingComponent && React.createElement(loadingComponent)}
|
||||
</div>
|
||||
</div>
|
||||
{/* <div style={{ clear: "both" }} /> */}
|
||||
|
||||
<div
|
||||
ref={nodeRef}
|
||||
id="bottom"
|
||||
className="bottom"
|
||||
style={{ display: hasMore ? "block" : "none" }}
|
||||
>
|
||||
{loadingComponent && React.createElement(loadingComponent)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LoadMore.displayName = "LoadMore"
|
||||
|
||||
export default LoadMore
|
||||
|
@ -1,9 +1,10 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import classnames from "classnames"
|
||||
|
||||
import { WithPlayerContext } from "@contexts/WithPlayerContext"
|
||||
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
|
||||
import {
|
||||
WithPlayerContext,
|
||||
usePlayerStateContext,
|
||||
} from "@contexts/WithPlayerContext"
|
||||
|
||||
import LoadMore from "@components/LoadMore"
|
||||
import { Icons } from "@components/Icons"
|
||||
@ -24,6 +25,8 @@ const TrackList = ({
|
||||
hasMore,
|
||||
noHeader = false,
|
||||
}) => {
|
||||
const [{ track_manifest, playback_status }] = usePlayerStateContext()
|
||||
|
||||
const showListHeader = !noHeader && (tracks.length > 0 || searchResults)
|
||||
|
||||
if (!searchResults && tracks.length === 0) {
|
||||
@ -62,7 +65,7 @@ const TrackList = ({
|
||||
key={item._id}
|
||||
order={item._id} // Consider using index if order matters
|
||||
track={item}
|
||||
onPlay={() => onTrackClick(item)}
|
||||
onPlay={onTrackClick}
|
||||
changeState={(update) =>
|
||||
onTrackStateChange(item._id, update)
|
||||
}
|
||||
@ -76,19 +79,19 @@ const TrackList = ({
|
||||
onBottom={onLoadMore}
|
||||
hasMore={hasMore}
|
||||
>
|
||||
<WithPlayerContext>
|
||||
{tracks.map((item, index) => (
|
||||
<MusicTrack
|
||||
key={item._id} // Use unique ID for key
|
||||
order={index + 1}
|
||||
track={item}
|
||||
onPlay={() => onTrackClick(item)}
|
||||
changeState={(update) =>
|
||||
onTrackStateChange(item._id, update)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</WithPlayerContext>
|
||||
{tracks.map((item, index) => (
|
||||
<MusicTrack
|
||||
key={item._id}
|
||||
order={index + 1}
|
||||
track={item}
|
||||
onPlay={onTrackClick}
|
||||
isCurrent={item._id === track_manifest?._id}
|
||||
isPlaying={
|
||||
item._id === track_manifest?._id &&
|
||||
playback_status === "playing"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</LoadMore>
|
||||
)}
|
||||
</div>
|
||||
|
@ -2,140 +2,37 @@ import React from "react"
|
||||
import * as antd from "antd"
|
||||
import classnames from "classnames"
|
||||
|
||||
import RGBStringToValues from "@utils/rgbToValues"
|
||||
|
||||
import ImageViewer from "@components/ImageViewer"
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import MusicModel from "@models/music"
|
||||
import MenuItemsBase from "./menuItems"
|
||||
import MenuHandlers from "./menuHandlers"
|
||||
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
import RGBStringToValues from "@utils/rgbToValues"
|
||||
import { Context as PlaylistContext } from "@contexts/WithPlaylistContext"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const handlers = {
|
||||
like: async (ctx, track) => {
|
||||
await MusicModel.toggleItemFavourite("track", track._id, true)
|
||||
function secondsToIsoTime(seconds) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
|
||||
ctx.changeState({
|
||||
liked: true,
|
||||
})
|
||||
ctx.closeMenu()
|
||||
},
|
||||
unlike: async (ctx, track) => {
|
||||
await MusicModel.toggleItemFavourite("track", track._id, false)
|
||||
|
||||
ctx.changeState({
|
||||
liked: false,
|
||||
})
|
||||
ctx.closeMenu()
|
||||
},
|
||||
add_to_playlist: async (ctx, track) => {},
|
||||
add_to_queue: async (ctx, track) => {
|
||||
await app.cores.player.queue.add(track)
|
||||
},
|
||||
play_next: async (ctx, track) => {
|
||||
await app.cores.player.queue.add(track, { next: true })
|
||||
},
|
||||
return `${minutes}:${Math.floor(seconds % 60)
|
||||
.toString()
|
||||
.padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const Track = (props) => {
|
||||
const [{ loading, track_manifest, playback_status }] =
|
||||
usePlayerStateContext()
|
||||
|
||||
const Track = React.memo((props) => {
|
||||
const playlist_ctx = React.useContext(PlaylistContext)
|
||||
|
||||
const [moreMenuOpened, setMoreMenuOpened] = React.useState(false)
|
||||
const [liked, setLiked] = React.useState(props.track.liked)
|
||||
|
||||
const isCurrent = track_manifest?._id === props.track._id
|
||||
const isPlaying = isCurrent && playback_status === "playing"
|
||||
const trackDuration = React.useMemo(() => {
|
||||
return props.track?.metadata?.duration ?? props.track?.duration
|
||||
}, [props.track])
|
||||
|
||||
const handleClickPlayBtn = React.useCallback(() => {
|
||||
if (typeof props.onPlay === "function") {
|
||||
return props.onPlay(props.track)
|
||||
}
|
||||
|
||||
if (typeof props.onClickPlayBtn === "function") {
|
||||
props.onClickPlayBtn(props.track)
|
||||
}
|
||||
|
||||
if (!isCurrent) {
|
||||
app.cores.player.start(props.track)
|
||||
} else {
|
||||
app.cores.player.playback.toggle()
|
||||
}
|
||||
})
|
||||
|
||||
const handleOnClickItem = React.useCallback(() => {
|
||||
if (props.onClick) {
|
||||
props.onClick(props.track)
|
||||
}
|
||||
|
||||
if (app.isMobile) {
|
||||
handleClickPlayBtn()
|
||||
}
|
||||
})
|
||||
|
||||
const handleMoreMenuOpen = () => {
|
||||
if (app.isMobile) {
|
||||
return
|
||||
}
|
||||
|
||||
return setMoreMenuOpened((prev) => {
|
||||
return !prev
|
||||
})
|
||||
}
|
||||
|
||||
const handleMoreMenuItemClick = (e) => {
|
||||
const { key } = e
|
||||
|
||||
if (typeof handlers[key] === "function") {
|
||||
return handlers[key](
|
||||
{
|
||||
closeMenu: () => {
|
||||
setMoreMenuOpened(false)
|
||||
},
|
||||
changeState: props.changeState,
|
||||
},
|
||||
props.track,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const moreMenuItems = React.useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
key: "like",
|
||||
icon: <Icons.MdFavorite />,
|
||||
label: "Like",
|
||||
},
|
||||
{
|
||||
key: "share",
|
||||
icon: <Icons.MdShare />,
|
||||
label: "Share",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: "add_to_playlist",
|
||||
icon: <Icons.MdPlaylistAdd />,
|
||||
label: "Add to playlist",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
key: "add_to_queue",
|
||||
icon: <Icons.MdQueueMusic />,
|
||||
label: "Add to queue",
|
||||
},
|
||||
{
|
||||
key: "play_next",
|
||||
icon: <Icons.MdSkipNext />,
|
||||
label: "Play next",
|
||||
},
|
||||
]
|
||||
const menuItems = React.useMemo(() => {
|
||||
const items = [...MenuItemsBase]
|
||||
|
||||
if (props.track.liked) {
|
||||
items[0] = {
|
||||
@ -162,19 +59,68 @@ const Track = (props) => {
|
||||
return items
|
||||
}, [props.track])
|
||||
|
||||
const handleClickPlayBtn = React.useCallback(() => {
|
||||
if (typeof props.onPlay === "function") {
|
||||
return props.onPlay(props.track)
|
||||
}
|
||||
|
||||
if (typeof props.onClickPlayBtn === "function") {
|
||||
props.onClickPlayBtn(props.track)
|
||||
}
|
||||
|
||||
if (!props.isCurrent) {
|
||||
app.cores.player.start(props.track)
|
||||
} else {
|
||||
app.cores.player.playback.toggle()
|
||||
}
|
||||
}, [props.isCurrent])
|
||||
|
||||
const handleOnClickItem = React.useCallback(() => {
|
||||
if (props.onClick) {
|
||||
props.onClick(props.track)
|
||||
}
|
||||
|
||||
if (app.isMobile) {
|
||||
handleClickPlayBtn()
|
||||
}
|
||||
}, [props.track])
|
||||
|
||||
const handleMoreMenuOpen = React.useCallback(() => {
|
||||
if (app.isMobile) {
|
||||
return
|
||||
}
|
||||
|
||||
return setMoreMenuOpened((prev) => {
|
||||
return !prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleMoreMenuItemClick = React.useCallback(
|
||||
(e) => {
|
||||
const { key } = e
|
||||
|
||||
if (typeof MenuHandlers[key] === "function") {
|
||||
return MenuHandlers[key](
|
||||
{
|
||||
close: () => {
|
||||
setMoreMenuOpened(false)
|
||||
},
|
||||
setLiked: setLiked,
|
||||
},
|
||||
props.track,
|
||||
)
|
||||
}
|
||||
},
|
||||
[props.track],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
id={props.track._id}
|
||||
className={classnames("music-track", {
|
||||
["current"]: isCurrent,
|
||||
["playing"]: isPlaying,
|
||||
["loading"]: isCurrent && loading,
|
||||
["current"]: props.isCurrent,
|
||||
["playing"]: props.isPlaying,
|
||||
})}
|
||||
style={{
|
||||
"--cover_average-color": RGBStringToValues(
|
||||
track_manifest?.cover_analysis?.rgb,
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="music-track_background" />
|
||||
|
||||
@ -193,7 +139,7 @@ const Track = (props) => {
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={
|
||||
isPlaying ? (
|
||||
props.isPlaying ? (
|
||||
<Icons.MdPause />
|
||||
) : (
|
||||
<Icons.MdPlayArrow />
|
||||
@ -214,13 +160,19 @@ const Track = (props) => {
|
||||
className="music-track_details"
|
||||
onClick={handleOnClickItem}
|
||||
>
|
||||
<div className="music-track_title">
|
||||
<span>
|
||||
<div className="music-track_titles">
|
||||
<span className="music-track_title">
|
||||
{props.track.service === "tidal" && (
|
||||
<Icons.SiTidal />
|
||||
)}
|
||||
{props.track.title}
|
||||
</span>
|
||||
|
||||
{props.track.version && (
|
||||
<span className="music-track_version">
|
||||
({props.track.version})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="music-track_artist">
|
||||
<span>
|
||||
@ -233,24 +185,31 @@ const Track = (props) => {
|
||||
</div>
|
||||
|
||||
<div className="music-track_actions">
|
||||
{trackDuration && (
|
||||
<div className="music-track_play_duration">
|
||||
<Icons.FiClock />
|
||||
{secondsToIsoTime(trackDuration)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<antd.Dropdown
|
||||
menu={{
|
||||
items: moreMenuItems,
|
||||
items: menuItems,
|
||||
onClick: handleMoreMenuItemClick,
|
||||
}}
|
||||
onOpenChange={handleMoreMenuOpen}
|
||||
open={moreMenuOpened}
|
||||
trigger={["click"]}
|
||||
>
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
size="large"
|
||||
icon={<Icons.IoMdMore />}
|
||||
/>
|
||||
<div className="music-track_more-menu">
|
||||
<Icons.IoMdMore />
|
||||
</div>
|
||||
</antd.Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Track.displayName = "Track"
|
||||
|
||||
export default Track
|
||||
|
@ -49,6 +49,11 @@ html {
|
||||
.music-track_orderIndex {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.music-track_play_duration {
|
||||
opacity: 1;
|
||||
width: fit-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -113,6 +118,13 @@ html {
|
||||
.music-track_play {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
cursor: pointer;
|
||||
|
||||
@ -200,13 +212,31 @@ html {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
width: 75%;
|
||||
|
||||
color: var(--text-color);
|
||||
|
||||
.music-track_title {
|
||||
font-size: 1rem;
|
||||
//font-family: "Space Grotesk", sans-serif;
|
||||
.music-track_titles {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 5px;
|
||||
|
||||
.music-track_title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.music-track_version {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.music-track_artist {
|
||||
@ -225,10 +255,27 @@ html {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
gap: 10px;
|
||||
gap: 5px;
|
||||
|
||||
margin-left: auto;
|
||||
|
||||
color: var(--text-color);
|
||||
|
||||
.music-track_play_duration {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
gap: 5px;
|
||||
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.music-track_more-menu {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
31
packages/app/src/components/Music/Track/menuHandlers.js
Normal file
31
packages/app/src/components/Music/Track/menuHandlers.js
Normal file
@ -0,0 +1,31 @@
|
||||
import MusicModel from "@models/music"
|
||||
|
||||
export default {
|
||||
like: async (ctx, track) => {
|
||||
await MusicModel.toggleItemFavourite("track", track._id, true)
|
||||
|
||||
ctx.changeState({
|
||||
liked: true,
|
||||
})
|
||||
ctx.close()
|
||||
},
|
||||
unlike: async (ctx, track) => {
|
||||
await MusicModel.toggleItemFavourite("track", track._id, false)
|
||||
|
||||
ctx.changeState({
|
||||
liked: false,
|
||||
})
|
||||
ctx.close()
|
||||
},
|
||||
add_to_playlist: async (ctx, track) => {},
|
||||
add_to_queue: async (ctx, track) => {
|
||||
await app.cores.player.queue.add(track)
|
||||
},
|
||||
play_next: async (ctx, track) => {
|
||||
await app.cores.player.queue.add(track, { next: true })
|
||||
},
|
||||
copy_id: (ctx, track) => {
|
||||
console.log("copy_id", track)
|
||||
navigator.clipboard.writeText(track._id)
|
||||
},
|
||||
}
|
42
packages/app/src/components/Music/Track/menuItems.jsx
Normal file
42
packages/app/src/components/Music/Track/menuItems.jsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
export default [
|
||||
{
|
||||
key: "like",
|
||||
icon: <Icons.MdFavorite />,
|
||||
label: "Like",
|
||||
},
|
||||
{
|
||||
key: "share",
|
||||
icon: <Icons.MdShare />,
|
||||
label: "Share",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: "add_to_playlist",
|
||||
icon: <Icons.MdPlaylistAdd />,
|
||||
label: "Add to playlist",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
key: "add_to_queue",
|
||||
icon: <Icons.MdQueueMusic />,
|
||||
label: "Add to queue",
|
||||
},
|
||||
{
|
||||
key: "play_next",
|
||||
icon: <Icons.MdSkipNext />,
|
||||
label: "Play next",
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
key: "copy_id",
|
||||
icon: <Icons.MdLink />,
|
||||
label: "Copy ID",
|
||||
},
|
||||
]
|
@ -1,101 +0,0 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import LyricsTextView from "@components/MusicStudio/LyricsTextView"
|
||||
import UploadButton from "@components/UploadButton"
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import Languages from "@config/languages"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const LanguagesMap = Object.entries(Languages).map(([key, value]) => {
|
||||
return {
|
||||
label: value,
|
||||
value: key,
|
||||
}
|
||||
})
|
||||
|
||||
const LyricsEditor = (props) => {
|
||||
const { langs = {} } = props
|
||||
const [selectedLang, setSelectedLang] = React.useState("original")
|
||||
|
||||
function handleChange(key, value) {
|
||||
if (typeof props.onChange !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
props.onChange(key, value)
|
||||
}
|
||||
|
||||
function updateCurrentLang(url) {
|
||||
handleChange("langs", {
|
||||
...langs,
|
||||
[selectedLang]: url,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="lyrics-editor">
|
||||
<div className="flex-row align-center justify-space-between gap-10">
|
||||
<h1>
|
||||
<Icons.MdOutlineMusicNote />
|
||||
Lyrics
|
||||
</h1>
|
||||
|
||||
<div className="flex-row aling-center gap-5">
|
||||
<span>Language:</span>
|
||||
|
||||
<antd.Select
|
||||
showSearch
|
||||
style={{ width: "220px" }}
|
||||
placeholder="Select a language"
|
||||
value={selectedLang}
|
||||
options={[
|
||||
...LanguagesMap,
|
||||
{
|
||||
label: "Original",
|
||||
value: "original",
|
||||
},
|
||||
]}
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
(option?.label.toLowerCase() ?? "").includes(
|
||||
input.toLowerCase(),
|
||||
)
|
||||
}
|
||||
filterSort={(optionA, optionB) =>
|
||||
(optionA?.label.toLowerCase() ?? "")
|
||||
.toLowerCase()
|
||||
.localeCompare(
|
||||
(
|
||||
optionB?.label.toLowerCase() ?? ""
|
||||
).toLowerCase(),
|
||||
)
|
||||
}
|
||||
onChange={setSelectedLang}
|
||||
/>
|
||||
|
||||
{selectedLang && (
|
||||
<UploadButton
|
||||
onSuccess={(file_uid, data) => {
|
||||
updateCurrentLang(data.url)
|
||||
}}
|
||||
accept={["text/*"]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!langs[selectedLang] && (
|
||||
<span>No lyrics uploaded for this language</span>
|
||||
)}
|
||||
|
||||
{langs[selectedLang] && (
|
||||
<LyricsTextView lrcURL={langs[selectedLang]} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LyricsEditor
|
@ -1,11 +0,0 @@
|
||||
.lyrics-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 20px;
|
||||
padding: 15px;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
}
|
@ -1,107 +0,0 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import dayjs from "dayjs"
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat"
|
||||
|
||||
import UploadButton from "@components/UploadButton"
|
||||
import { Icons } from "@components/Icons"
|
||||
import VideoPlayer from "@components/VideoPlayer"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
const VideoEditor = (props) => {
|
||||
function handleChange(key, value) {
|
||||
if (typeof props.onChange !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
props.onChange(key, value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="video-editor">
|
||||
<h1>
|
||||
<Icons.MdVideocam />
|
||||
Video
|
||||
</h1>
|
||||
|
||||
{!props.videoSourceURL && (
|
||||
<antd.Empty
|
||||
image={<Icons.MdVideocam />}
|
||||
description="No video"
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.videoSourceURL && (
|
||||
<div className="video-editor-preview">
|
||||
<VideoPlayer
|
||||
controls={[
|
||||
"play",
|
||||
"current-time",
|
||||
"seek-time",
|
||||
"duration",
|
||||
"progress",
|
||||
"settings",
|
||||
]}
|
||||
src={props.videoSourceURL}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-column align-start gap10">
|
||||
<div className="flex-row align-center gap10">
|
||||
<span>
|
||||
<Icons.MdAccessTime />
|
||||
Start video sync at
|
||||
</span>
|
||||
|
||||
<code>{props.startSyncAt ?? "not set"}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex-row align-center gap10">
|
||||
<span>Set to:</span>
|
||||
|
||||
<antd.TimePicker
|
||||
showNow={false}
|
||||
defaultValue={
|
||||
props.startSyncAt &&
|
||||
dayjs(props.startSyncAt, "mm:ss:SSS")
|
||||
}
|
||||
format={"mm:ss:SSS"}
|
||||
onChange={(time, str) => {
|
||||
handleChange("startSyncAt", str)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="video-editor-actions">
|
||||
<UploadButton
|
||||
onSuccess={(id, response) => {
|
||||
handleChange("videoSourceURL", response.url)
|
||||
}}
|
||||
accept={["video/*"]}
|
||||
headers={{
|
||||
transformations: "mq-hls",
|
||||
}}
|
||||
disabled={props.loading}
|
||||
>
|
||||
Upload video
|
||||
</UploadButton>
|
||||
or
|
||||
<antd.Input
|
||||
placeholder="Set a video HLS URL"
|
||||
onChange={(e) => {
|
||||
handleChange("videoSourceURL", e.target.value)
|
||||
}}
|
||||
value={props.videoSourceURL}
|
||||
disabled={props.loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoEditor
|
@ -1,25 +0,0 @@
|
||||
.video-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 20px;
|
||||
padding: 15px;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
|
||||
.video-editor-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.video-editor-preview {
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
}
|
||||
}
|
@ -1,122 +0,0 @@
|
||||
import React from "react"
|
||||
import { Skeleton } from "antd"
|
||||
|
||||
import VideoEditor from "./components/VideoEditor"
|
||||
import LyricsEditor from "./components/LyricsEditor"
|
||||
|
||||
import MusicModel from "@models/music"
|
||||
|
||||
import ReleaseEditorStateContext from "@contexts/MusicReleaseEditor"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
class EnhancedLyricsEditor extends React.Component {
|
||||
static contextType = ReleaseEditorStateContext
|
||||
|
||||
state = {
|
||||
data: {},
|
||||
loading: true,
|
||||
submitting: false,
|
||||
videoOptions: {},
|
||||
lyricsOptions: {}
|
||||
}
|
||||
|
||||
componentDidMount = async () => {
|
||||
this.setState({
|
||||
loading: true
|
||||
})
|
||||
|
||||
this.context.setCustomPageActions([
|
||||
{
|
||||
label: "Save",
|
||||
icon: "FiSave",
|
||||
onClick: this.submitChanges,
|
||||
}
|
||||
])
|
||||
|
||||
const data = await MusicModel.getTrackLyrics(this.props.track._id).catch((err) => {
|
||||
return null
|
||||
})
|
||||
|
||||
if (data) {
|
||||
this.setState({
|
||||
videoOptions: {
|
||||
videoSourceURL: data.video_source,
|
||||
startSyncAt: data.sync_audio_at
|
||||
},
|
||||
lyricsOptions: {
|
||||
langs: data.lrc
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: false
|
||||
})
|
||||
}
|
||||
|
||||
submitChanges = async () => {
|
||||
this.setState({
|
||||
submitting: true
|
||||
})
|
||||
|
||||
console.log(`Submitting changes with values >`, {
|
||||
...this.state.videoOptions,
|
||||
...this.state.lyricsOptions
|
||||
})
|
||||
|
||||
await MusicModel.putTrackLyrics(this.props.track._id, {
|
||||
video_source: this.state.videoOptions.videoSourceURL,
|
||||
sync_audio_at: this.state.videoOptions.startSyncAt,
|
||||
lrc: this.state.lyricsOptions.langs
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
app.message.error("Failed to update enhanced lyrics")
|
||||
})
|
||||
|
||||
app.message.success("Lyrics updated")
|
||||
|
||||
this.setState({
|
||||
submitting: false
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.loading) {
|
||||
return <Skeleton active />
|
||||
}
|
||||
|
||||
return <div className="enhanced_lyrics_editor-wrapper">
|
||||
<h1>{this.props.track.title}</h1>
|
||||
|
||||
<VideoEditor
|
||||
loading={this.state.submitting}
|
||||
videoSourceURL={this.state.videoOptions.videoSourceURL}
|
||||
startSyncAt={this.state.videoOptions.startSyncAt}
|
||||
onChange={(key, value) => {
|
||||
this.setState({
|
||||
videoOptions: {
|
||||
...this.state.videoOptions,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<LyricsEditor
|
||||
loading={this.state.submitting}
|
||||
langs={this.state.lyricsOptions.langs}
|
||||
onChange={(key, value) => {
|
||||
this.setState({
|
||||
lyricsOptions: {
|
||||
...this.state.lyricsOptions,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default EnhancedLyricsEditor
|
@ -1,6 +0,0 @@
|
||||
.enhanced_lyrics_editor-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 20px;
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import axios from "axios"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const LyricsTextView = (props) => {
|
||||
const { lrcURL } = props
|
||||
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState(null)
|
||||
const [lyrics, setLyrics] = React.useState(null)
|
||||
|
||||
async function getLyrics(resource_url) {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
setLyrics(null)
|
||||
|
||||
const data = await axios({
|
||||
method: "get",
|
||||
url: resource_url,
|
||||
responseType: "text"
|
||||
}).catch((err) => {
|
||||
console.error(err)
|
||||
setError(err)
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
if (data) {
|
||||
setLyrics(data.data.split("\n"))
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
getLyrics(lrcURL)
|
||||
}, [lrcURL])
|
||||
|
||||
if (!lrcURL) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <antd.Result
|
||||
status="warning"
|
||||
title="Failed"
|
||||
subTitle={error.message}
|
||||
/>
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
|
||||
if (!lyrics) {
|
||||
return <p>No lyrics provided</p>
|
||||
}
|
||||
|
||||
return <div className="lyrics-text-view">
|
||||
{
|
||||
lyrics?.map((line, index) => {
|
||||
return <div
|
||||
key={index}
|
||||
className="lyrics-text-view-line"
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default LyricsTextView
|
@ -1,15 +0,0 @@
|
||||
.lyrics-text-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.lyrics-text-view-line {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import ReleaseItem from "@components/MusicStudio/ReleaseItem"
|
||||
|
||||
import MusicModel from "@models/music"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const MyReleasesList = () => {
|
||||
const [L_MyReleases, R_MyReleases, E_MyReleases, M_MyReleases] = app.cores.api.useRequest(MusicModel.getMyReleases, {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
async function onClickReleaseItem(release) {
|
||||
app.location.push(`/studio/music/${release._id}`)
|
||||
}
|
||||
|
||||
return <div className="music-studio-page-content">
|
||||
<div className="music-studio-page-header">
|
||||
<h1>Your Releases</h1>
|
||||
</div>
|
||||
|
||||
{
|
||||
L_MyReleases && !E_MyReleases && <antd.Skeleton active />
|
||||
}
|
||||
{
|
||||
E_MyReleases && <antd.Result
|
||||
status="warning"
|
||||
title="Failed to retrieve releases"
|
||||
subTitle={E_MyReleases.message}
|
||||
/>
|
||||
}
|
||||
{
|
||||
!L_MyReleases && !E_MyReleases && R_MyReleases && R_MyReleases.items.length === 0 && <antd.Empty />
|
||||
}
|
||||
|
||||
{
|
||||
!L_MyReleases && !E_MyReleases && R_MyReleases && R_MyReleases.items.length > 0 && <div className="music-studio-page-releases-list">
|
||||
{
|
||||
R_MyReleases.items.map((item) => {
|
||||
return <ReleaseItem
|
||||
key={item._id}
|
||||
release={item}
|
||||
onClick={onClickReleaseItem}
|
||||
/>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default MyReleasesList
|
@ -1,332 +0,0 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import { Icons, createIconRender } from "@components/Icons"
|
||||
|
||||
import MusicModel from "@models/music"
|
||||
import compareObjectsByProperties from "@utils/compareObjectsByProperties"
|
||||
import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
|
||||
|
||||
import TrackManifest from "@cores/player/classes/TrackManifest"
|
||||
|
||||
import {
|
||||
DefaultReleaseEditorState,
|
||||
ReleaseEditorStateContext,
|
||||
} from "@contexts/MusicReleaseEditor"
|
||||
|
||||
import Tabs from "./tabs"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const ReleaseEditor = (props) => {
|
||||
const { release_id } = props
|
||||
|
||||
const basicInfoRef = React.useRef()
|
||||
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [submitError, setSubmitError] = React.useState(null)
|
||||
|
||||
const [loadError, setLoadError] = React.useState(null)
|
||||
const [globalState, setGlobalState] = React.useState(
|
||||
DefaultReleaseEditorState,
|
||||
)
|
||||
const [initialValues, setInitialValues] = React.useState({})
|
||||
|
||||
const [customPage, setCustomPage] = React.useState(null)
|
||||
const [customPageActions, setCustomPageActions] = React.useState([])
|
||||
|
||||
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
|
||||
defaultKey: "info",
|
||||
queryKey: "tab",
|
||||
})
|
||||
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
|
||||
if (release_id !== "new") {
|
||||
try {
|
||||
let releaseData = await MusicModel.getReleaseData(release_id)
|
||||
|
||||
if (Array.isArray(releaseData.items)) {
|
||||
releaseData.items = releaseData.items.map((item) => {
|
||||
return new TrackManifest(item)
|
||||
})
|
||||
}
|
||||
|
||||
setGlobalState({
|
||||
...globalState,
|
||||
...releaseData,
|
||||
})
|
||||
|
||||
setInitialValues(releaseData)
|
||||
} catch (error) {
|
||||
setLoadError(error)
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
function hasChanges() {
|
||||
const stagedChanges = {
|
||||
title: globalState.title,
|
||||
type: globalState.type,
|
||||
public: globalState.public,
|
||||
cover: globalState.cover,
|
||||
items: globalState.items,
|
||||
}
|
||||
|
||||
return !compareObjectsByProperties(
|
||||
stagedChanges,
|
||||
initialValues,
|
||||
Object.keys(stagedChanges),
|
||||
)
|
||||
}
|
||||
|
||||
async function renderCustomPage(page, actions) {
|
||||
setCustomPage(page ?? null)
|
||||
setCustomPageActions(actions ?? [])
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
|
||||
try {
|
||||
console.log("Submitting Tracks")
|
||||
|
||||
// first sumbit tracks
|
||||
const tracks = await MusicModel.putTrack({
|
||||
items: globalState.items,
|
||||
})
|
||||
|
||||
console.log("Submitting release")
|
||||
|
||||
// then submit release
|
||||
const result = await MusicModel.putRelease({
|
||||
_id: globalState._id,
|
||||
title: globalState.title,
|
||||
description: globalState.description,
|
||||
public: globalState.public,
|
||||
cover: globalState.cover,
|
||||
explicit: globalState.explicit,
|
||||
type: globalState.type,
|
||||
items: tracks.items.map((item) => item._id),
|
||||
})
|
||||
|
||||
app.location.push(`/studio/music/${result._id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
app.message.error(error.message)
|
||||
|
||||
setSubmitError(error)
|
||||
setSubmitting(false)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
setSubmitting(false)
|
||||
app.message.success("Release saved")
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
app.layout.modal.confirm({
|
||||
headerText: "Are you sure you want to delete this release?",
|
||||
descriptionText: "This action cannot be undone.",
|
||||
onConfirm: async () => {
|
||||
await MusicModel.deleteRelease(globalState._id)
|
||||
app.location.push(
|
||||
window.location.pathname.split("/").slice(0, -1).join("/"),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function canFinish() {
|
||||
return hasChanges()
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
initialize()
|
||||
}, [])
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<antd.Result
|
||||
status="warning"
|
||||
title="Error"
|
||||
subTitle={loadError.message}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
|
||||
const Tab = Tabs.find(({ key }) => key === selectedTab)
|
||||
|
||||
const CustomPageProps = {
|
||||
close: () => {
|
||||
renderCustomPage(null, null)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ReleaseEditorStateContext.Provider
|
||||
value={{
|
||||
...globalState,
|
||||
setGlobalState,
|
||||
renderCustomPage,
|
||||
setCustomPageActions,
|
||||
}}
|
||||
>
|
||||
<div className="music-studio-release-editor">
|
||||
{customPage && (
|
||||
<div className="music-studio-release-editor-custom-page">
|
||||
{customPage.header && (
|
||||
<div className="music-studio-release-editor-custom-page-header">
|
||||
<div className="music-studio-release-editor-custom-page-header-title">
|
||||
<antd.Button
|
||||
icon={<Icons.IoIosArrowBack />}
|
||||
onClick={() =>
|
||||
renderCustomPage(null, null)
|
||||
}
|
||||
/>
|
||||
|
||||
<h2>{customPage.header}</h2>
|
||||
</div>
|
||||
|
||||
{Array.isArray(customPageActions) &&
|
||||
customPageActions.map((action, index) => {
|
||||
return (
|
||||
<antd.Button
|
||||
key={index}
|
||||
type={action.type}
|
||||
icon={createIconRender(
|
||||
action.icon,
|
||||
)}
|
||||
onClick={async () => {
|
||||
if (
|
||||
typeof action.onClick ===
|
||||
"function"
|
||||
) {
|
||||
await action.onClick()
|
||||
}
|
||||
|
||||
if (action.fireEvent) {
|
||||
app.eventBus.emit(
|
||||
action.fireEvent,
|
||||
)
|
||||
}
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.label}
|
||||
</antd.Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customPage.content &&
|
||||
(React.isValidElement(customPage.content)
|
||||
? React.cloneElement(customPage.content, {
|
||||
...CustomPageProps,
|
||||
...customPage.props,
|
||||
})
|
||||
: React.createElement(customPage.content, {
|
||||
...CustomPageProps,
|
||||
...customPage.props,
|
||||
}))}
|
||||
</div>
|
||||
)}
|
||||
{!customPage && (
|
||||
<>
|
||||
<div className="music-studio-release-editor-menu">
|
||||
<antd.Menu
|
||||
onClick={(e) => setSelectedTab(e.key)}
|
||||
selectedKeys={[selectedTab]}
|
||||
items={Tabs}
|
||||
mode="vertical"
|
||||
/>
|
||||
|
||||
<div className="music-studio-release-editor-menu-actions">
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
icon={
|
||||
release_id !== "new" ? (
|
||||
<Icons.FiSave />
|
||||
) : (
|
||||
<Icons.MdSend />
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
submitting || loading || !canFinish()
|
||||
}
|
||||
loading={submitting}
|
||||
>
|
||||
{release_id !== "new" ? "Save" : "Release"}
|
||||
</antd.Button>
|
||||
|
||||
{release_id !== "new" ? (
|
||||
<antd.Button
|
||||
icon={<Icons.IoMdTrash />}
|
||||
disabled={loading}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</antd.Button>
|
||||
) : null}
|
||||
|
||||
{release_id !== "new" ? (
|
||||
<antd.Button
|
||||
icon={<Icons.MdLink />}
|
||||
onClick={() =>
|
||||
app.location.push(
|
||||
`/music/list/${globalState._id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Go to release
|
||||
</antd.Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="music-studio-release-editor-content">
|
||||
{submitError && (
|
||||
<antd.Alert
|
||||
message={submitError.message}
|
||||
type="error"
|
||||
/>
|
||||
)}
|
||||
{!Tab && (
|
||||
<antd.Result
|
||||
status="error"
|
||||
title="Error"
|
||||
subTitle="Tab not found"
|
||||
/>
|
||||
)}
|
||||
{Tab &&
|
||||
React.createElement(Tab.render, {
|
||||
release: globalState,
|
||||
|
||||
state: globalState,
|
||||
setState: setGlobalState,
|
||||
|
||||
references: {
|
||||
basic: basicInfoRef,
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ReleaseEditorStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReleaseEditor
|
@ -1,136 +0,0 @@
|
||||
.music-studio-release-editor {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
width: 100%;
|
||||
|
||||
//padding: 20px;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
.music-studio-release-editor-custom-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
.music-studio-release-editor-custom-page-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
|
||||
padding: 10px;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
span {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.music-studio-release-editor-custom-page-header-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.music-studio-release-editor-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
.title {
|
||||
font-size: 1.7rem;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
.music-studio-release-editor-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
align-items: center;
|
||||
|
||||
.ant-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-menu {
|
||||
background-color: var(--background-color-accent) !important;
|
||||
border-radius: 12px;
|
||||
|
||||
padding: 8px;
|
||||
|
||||
gap: 5px;
|
||||
|
||||
.ant-menu-item {
|
||||
padding: 5px 10px !important;
|
||||
}
|
||||
|
||||
.ant-menu-item-selected {
|
||||
background-color: var(--background-color-primary-2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.music-studio-release-editor-menu-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.music-studio-release-editor-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
|
||||
.music-studio-release-editor-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
label {
|
||||
height: fit-content;
|
||||
|
||||
span {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,115 +0,0 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import CoverEditor from "@components/CoverEditor"
|
||||
|
||||
const ReleasesTypes = [
|
||||
{
|
||||
value: "single",
|
||||
label: "Single",
|
||||
icon: <Icons.MdMusicNote />,
|
||||
},
|
||||
{
|
||||
value: "ep",
|
||||
label: "Episode",
|
||||
icon: <Icons.MdAlbum />,
|
||||
},
|
||||
{
|
||||
value: "album",
|
||||
label: "Album",
|
||||
icon: <Icons.MdAlbum />,
|
||||
},
|
||||
{
|
||||
value: "compilation",
|
||||
label: "Compilation",
|
||||
icon: <Icons.MdAlbum />,
|
||||
}
|
||||
]
|
||||
|
||||
const BasicInformation = (props) => {
|
||||
const { release, onFinish, setState, state } = props
|
||||
|
||||
async function onFormChange(change) {
|
||||
setState((globalState) => {
|
||||
return {
|
||||
...globalState,
|
||||
...change
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return <div className="music-studio-release-editor-tab">
|
||||
<h1>Release Information</h1>
|
||||
|
||||
<antd.Form
|
||||
name="basic"
|
||||
layout="vertical"
|
||||
ref={props.references.basic}
|
||||
onFinish={onFinish}
|
||||
requiredMark={false}
|
||||
onValuesChange={onFormChange}
|
||||
>
|
||||
<antd.Form.Item
|
||||
label=""
|
||||
name="cover"
|
||||
rules={[{ required: true, message: "Input a cover for the release" }]}
|
||||
initialValue={state?.cover}
|
||||
>
|
||||
<CoverEditor
|
||||
defaultUrl="https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
||||
/>
|
||||
</antd.Form.Item>
|
||||
|
||||
{
|
||||
release._id && <antd.Form.Item
|
||||
label={<><Icons.MdTag /> <span>ID</span></>}
|
||||
name="_id"
|
||||
initialValue={release._id}
|
||||
disabled
|
||||
>
|
||||
<antd.Input
|
||||
placeholder="Release ID"
|
||||
disabled
|
||||
/>
|
||||
</antd.Form.Item>
|
||||
}
|
||||
|
||||
<antd.Form.Item
|
||||
label={<><Icons.MdMusicNote /> <span>Title</span></>}
|
||||
name="title"
|
||||
rules={[{ required: true, message: "Input a title for the release" }]}
|
||||
initialValue={state?.title}
|
||||
>
|
||||
<antd.Input
|
||||
placeholder="Release title"
|
||||
maxLength={128}
|
||||
showCount
|
||||
/>
|
||||
</antd.Form.Item>
|
||||
|
||||
<antd.Form.Item
|
||||
label={<><Icons.MdAlbum /> <span>Type</span></>}
|
||||
name="type"
|
||||
rules={[{ required: true, message: "Select a type for the release" }]}
|
||||
initialValue={state?.type}
|
||||
>
|
||||
<antd.Select
|
||||
placeholder="Release type"
|
||||
options={ReleasesTypes}
|
||||
/>
|
||||
</antd.Form.Item>
|
||||
|
||||
<antd.Form.Item
|
||||
label={<><Icons.MdPublic /> <span>Public</span></>}
|
||||
name="public"
|
||||
initialValue={state?.public}
|
||||
>
|
||||
<antd.Switch />
|
||||
</antd.Form.Item>
|
||||
</antd.Form>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default BasicInformation
|
@ -1,117 +0,0 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import classnames from "classnames"
|
||||
import { Draggable } from "react-beautiful-dnd"
|
||||
|
||||
import Image from "@components/Image"
|
||||
import { Icons } from "@components/Icons"
|
||||
import TrackEditor from "@components/MusicStudio/TrackEditor"
|
||||
|
||||
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const stateToString = {
|
||||
uploading: "Uploading",
|
||||
transmuxing: "Processing...",
|
||||
uploading_s3: "Archiving...",
|
||||
}
|
||||
|
||||
const getTitleString = ({ track, progress }) => {
|
||||
if (progress) {
|
||||
return stateToString[progress.state] || progress.state
|
||||
}
|
||||
|
||||
return track.title
|
||||
}
|
||||
|
||||
const TrackListItem = (props) => {
|
||||
const context = React.useContext(ReleaseEditorStateContext)
|
||||
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState(null)
|
||||
|
||||
const { track, progress } = props
|
||||
|
||||
async function onClickEditTrack() {
|
||||
context.renderCustomPage({
|
||||
header: "Track Editor",
|
||||
content: <TrackEditor />,
|
||||
props: {
|
||||
track: track,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function onClickRemoveTrack() {
|
||||
props.onDelete(track.uid)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
"music-studio-release-editor-tracks-list-item",
|
||||
{
|
||||
["loading"]: loading,
|
||||
["failed"]: !!error,
|
||||
["disabled"]: props.disabled,
|
||||
},
|
||||
)}
|
||||
data-swapy-item={track.id ?? track._id}
|
||||
>
|
||||
<div
|
||||
className="music-studio-release-editor-tracks-list-item-progress"
|
||||
style={{
|
||||
"--upload-progress": `${props.progress?.percent ?? 0}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="music-studio-release-editor-tracks-list-item-index">
|
||||
<span>{props.index + 1}</span>
|
||||
</div>
|
||||
|
||||
{progress !== null && <Icons.LoadingOutlined />}
|
||||
|
||||
<Image
|
||||
src={track.cover}
|
||||
height={25}
|
||||
width={25}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
|
||||
<span>{getTitleString({ track, progress })}</span>
|
||||
|
||||
<div className="music-studio-release-editor-tracks-list-item-actions">
|
||||
<antd.Popconfirm
|
||||
title="Are you sure you want to delete this track?"
|
||||
onConfirm={onClickRemoveTrack}
|
||||
okText="Yes"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
icon={<Icons.FiTrash2 />}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
</antd.Popconfirm>
|
||||
<antd.Button
|
||||
type="ghost"
|
||||
icon={<Icons.FiEdit2 />}
|
||||
onClick={onClickEditTrack}
|
||||
disabled={props.disabled}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-swapy-handle
|
||||
className="music-studio-release-editor-tracks-list-item-dragger"
|
||||
>
|
||||
<Icons.MdDragIndicator />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackListItem
|
@ -1,62 +0,0 @@
|
||||
.music-studio-release-editor-tracks-list-item {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
|
||||
padding: 10px;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
.music-studio-release-editor-tracks-list-item-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
width: var(--upload-progress);
|
||||
height: 2px;
|
||||
|
||||
background-color: var(--colorPrimary);
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
|
||||
.music-studio-release-editor-tracks-list-item-actions {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 100%;
|
||||
|
||||
padding: 0 5px;
|
||||
|
||||
svg {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.music-studio-release-editor-tracks-list-item-dragger {
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,352 +0,0 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import { createSwapy } from "swapy"
|
||||
|
||||
import queuedUploadFile from "@utils/queuedUploadFile"
|
||||
import FilesModel from "@models/files"
|
||||
|
||||
import TrackManifest from "@cores/player/classes/TrackManifest"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
import TrackListItem from "./components/TrackListItem"
|
||||
import UploadHint from "./components/UploadHint"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
class TracksManager extends React.Component {
|
||||
swapyRef = React.createRef()
|
||||
|
||||
state = {
|
||||
items: Array.isArray(this.props.items) ? this.props.items : [],
|
||||
pendingUploads: [],
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps, prevState) => {
|
||||
if (prevState.items !== this.state.items) {
|
||||
if (typeof this.props.onChangeState === "function") {
|
||||
this.props.onChangeState(this.state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.swapyRef.current = createSwapy(
|
||||
document.getElementById("editor-tracks-list"),
|
||||
{
|
||||
animation: "dynamic",
|
||||
dragAxis: "y",
|
||||
},
|
||||
)
|
||||
|
||||
this.swapyRef.current.onSwapEnd((event) => {
|
||||
console.log("end", event)
|
||||
this.orderTrackList(
|
||||
event.slotItemMap.asArray.map((item) => item.item),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.swapyRef.current.destroy()
|
||||
}
|
||||
|
||||
findTrackByUid = (uid) => {
|
||||
if (!uid) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.state.items.find((item) => item.uid === uid)
|
||||
}
|
||||
|
||||
addTrackToList = (track) => {
|
||||
if (!track) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.setState({
|
||||
items: [...this.state.items, track],
|
||||
})
|
||||
}
|
||||
|
||||
removeTrackByUid = (uid) => {
|
||||
if (!uid) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.removeTrackUIDFromPendingUploads(uid)
|
||||
|
||||
this.setState({
|
||||
items: this.state.items.filter((item) => item.uid !== uid),
|
||||
})
|
||||
}
|
||||
|
||||
modifyTrackByUid = (uid, track) => {
|
||||
if (!uid || !track) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.setState({
|
||||
items: this.state.items.map((item) => {
|
||||
if (item.uid === uid) {
|
||||
return {
|
||||
...item,
|
||||
...track,
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
addTrackUIDToPendingUploads = (uid) => {
|
||||
if (!uid) {
|
||||
return false
|
||||
}
|
||||
|
||||
const pendingUpload = this.state.pendingUploads.find(
|
||||
(item) => item.uid === uid,
|
||||
)
|
||||
|
||||
if (!pendingUpload) {
|
||||
this.setState({
|
||||
pendingUploads: [
|
||||
...this.state.pendingUploads,
|
||||
{
|
||||
uid: uid,
|
||||
progress: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
removeTrackUIDFromPendingUploads = (uid) => {
|
||||
if (!uid) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.setState({
|
||||
pendingUploads: this.state.pendingUploads.filter(
|
||||
(item) => item.uid !== uid,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
getUploadProgress = (uid) => {
|
||||
const uploadProgressIndex = this.state.pendingUploads.findIndex(
|
||||
(item) => item.uid === uid,
|
||||
)
|
||||
|
||||
if (uploadProgressIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.state.pendingUploads[uploadProgressIndex].progress
|
||||
}
|
||||
|
||||
updateUploadProgress = (uid, progress) => {
|
||||
const uploadProgressIndex = this.state.pendingUploads.findIndex(
|
||||
(item) => item.uid === uid,
|
||||
)
|
||||
|
||||
if (uploadProgressIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const newData = [...this.state.pendingUploads]
|
||||
|
||||
newData[uploadProgressIndex].progress = progress
|
||||
|
||||
console.log(`Updating progress for [${uid}] to >`, progress)
|
||||
|
||||
this.setState({
|
||||
pendingUploads: newData,
|
||||
})
|
||||
}
|
||||
|
||||
handleUploaderStateChange = async (change) => {
|
||||
const uid = change.file.uid
|
||||
|
||||
console.log("handleUploaderStateChange", change)
|
||||
|
||||
switch (change.file.status) {
|
||||
case "uploading": {
|
||||
this.addTrackUIDToPendingUploads(uid)
|
||||
|
||||
const trackManifest = new TrackManifest({
|
||||
uid: uid,
|
||||
file: change.file.originFileObj,
|
||||
})
|
||||
|
||||
this.addTrackToList(trackManifest)
|
||||
|
||||
break
|
||||
}
|
||||
case "done": {
|
||||
// remove pending file
|
||||
this.removeTrackUIDFromPendingUploads(uid)
|
||||
|
||||
let trackManifest = this.state.items.find(
|
||||
(item) => item.uid === uid,
|
||||
)
|
||||
|
||||
if (!trackManifest) {
|
||||
console.error(`Track with uid [${uid}] not found!`)
|
||||
break
|
||||
}
|
||||
|
||||
// // update track list
|
||||
// await this.modifyTrackByUid(uid, {
|
||||
// source: change.file.response.url
|
||||
// })
|
||||
|
||||
trackManifest.source = change.file.response.url
|
||||
trackManifest = await trackManifest.initialize()
|
||||
|
||||
// if has a cover, Upload
|
||||
if (trackManifest._coverBlob) {
|
||||
console.log(
|
||||
`[${trackManifest.uid}] Founded cover, uploading...`,
|
||||
)
|
||||
|
||||
const coverFile = new File(
|
||||
[trackManifest._coverBlob],
|
||||
"cover.jpg",
|
||||
{ type: trackManifest._coverBlob.type },
|
||||
)
|
||||
|
||||
const coverUpload = await FilesModel.upload(coverFile)
|
||||
|
||||
trackManifest.cover = coverUpload.url
|
||||
}
|
||||
|
||||
await this.modifyTrackByUid(uid, trackManifest)
|
||||
|
||||
break
|
||||
}
|
||||
case "error": {
|
||||
// remove pending file
|
||||
this.removeTrackUIDFromPendingUploads(uid)
|
||||
|
||||
// remove from tracklist
|
||||
await this.removeTrackByUid(uid)
|
||||
}
|
||||
case "removed": {
|
||||
// stop upload & delete from pending list and tracklist
|
||||
await this.removeTrackByUid(uid)
|
||||
}
|
||||
default: {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uploadToStorage = async (req) => {
|
||||
await queuedUploadFile(req.file, {
|
||||
onFinish: (file, response) => {
|
||||
req.onSuccess(response)
|
||||
},
|
||||
onError: req.onError,
|
||||
onProgress: this.handleTrackFileUploadProgress,
|
||||
headers: {
|
||||
transformations: "a-dash",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
handleTrackFileUploadProgress = async (file, progress) => {
|
||||
this.updateUploadProgress(file.uid, progress)
|
||||
}
|
||||
|
||||
orderTrackList = (orderedIdsArray) => {
|
||||
this.setState((prev) => {
|
||||
// move all list items by id
|
||||
const orderedIds = orderedIdsArray.map((id) =>
|
||||
this.state.items.find((item) => item._id === id),
|
||||
)
|
||||
console.log("orderedIds", orderedIds)
|
||||
return {
|
||||
items: orderedIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log(`Tracks List >`, this.state.items)
|
||||
|
||||
return (
|
||||
<div className="music-studio-release-editor-tracks">
|
||||
<antd.Upload
|
||||
className="music-studio-tracks-uploader"
|
||||
onChange={this.handleUploaderStateChange}
|
||||
customRequest={this.uploadToStorage}
|
||||
showUploadList={false}
|
||||
accept="audio/*"
|
||||
multiple
|
||||
>
|
||||
{this.state.items.length === 0 ? (
|
||||
<UploadHint />
|
||||
) : (
|
||||
<antd.Button
|
||||
className="uploadMoreButton"
|
||||
icon={<Icons.FiPlus />}
|
||||
>
|
||||
Add another
|
||||
</antd.Button>
|
||||
)}
|
||||
</antd.Upload>
|
||||
|
||||
<div
|
||||
id="editor-tracks-list"
|
||||
className="music-studio-release-editor-tracks-list"
|
||||
>
|
||||
{this.state.items.length === 0 && (
|
||||
<antd.Result status="info" title="No tracks" />
|
||||
)}
|
||||
|
||||
{this.state.items.map((track, index) => {
|
||||
const progress = this.getUploadProgress(track.uid)
|
||||
|
||||
return (
|
||||
<div data-swapy-slot={track._id ?? track.uid}>
|
||||
<TrackListItem
|
||||
index={index}
|
||||
track={track}
|
||||
onEdit={this.modifyTrackByUid}
|
||||
onDelete={this.removeTrackByUid}
|
||||
progress={progress}
|
||||
disabled={progress > 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const ReleaseTracks = (props) => {
|
||||
const { state, setState } = props
|
||||
|
||||
return (
|
||||
<div className="music-studio-release-editor-tab">
|
||||
<h1>Tracks</h1>
|
||||
|
||||
<TracksManager
|
||||
_id={state._id}
|
||||
items={state.items}
|
||||
onChangeState={(managerState) => {
|
||||
setState({
|
||||
...state,
|
||||
...managerState,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReleaseTracks
|
@ -1,33 +0,0 @@
|
||||
.music-studio-release-editor-tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
.music-studio-tracks-uploader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
.ant-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.music-studio-release-editor-tracks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 10px;
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
import React from "react"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
|
||||
export default function SortableItem({ id, children }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
cursor: "grab",
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
{children({
|
||||
...attributes,
|
||||
...listeners,
|
||||
ref: setActivatorNodeRef,
|
||||
style: { cursor: "grab", touchAction: "none" },
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SortableList({ items, renderItem, onOrder }) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
)
|
||||
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex((i) => i.id === active.id)
|
||||
const newIndex = items.findIndex((i) => i.id === over.id)
|
||||
const newItems = arrayMove(items, oldIndex, newIndex)
|
||||
onOrder(newItems)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<SortableContext
|
||||
items={items}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<SortableItem key={item.id} id={item.id}>
|
||||
{(handleProps) => (
|
||||
<div>
|
||||
{renderItem(item, index)}
|
||||
<div id="drag-handle" {...handleProps} />
|
||||
</div>
|
||||
)}
|
||||
</SortableItem>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
@ -1,51 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
import { Icons } from "@components/Icons"
|
||||
import Image from "@components/Image"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const ReleaseItem = (props) => {
|
||||
const { release, onClick } = props
|
||||
|
||||
async function handleOnClick() {
|
||||
if (typeof onClick === "function") {
|
||||
return onClick(release)
|
||||
}
|
||||
}
|
||||
|
||||
return <div
|
||||
id={release._id}
|
||||
className="music-studio-page-release"
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<div className="music-studio-page-release-title">
|
||||
<Image
|
||||
src={release.cover}
|
||||
/>
|
||||
|
||||
{release.title}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="music-studio-page-release-info"
|
||||
>
|
||||
<div className="music-studio-page-release-info-field">
|
||||
<Icons.IoMdMusicalNote />
|
||||
{release.type}
|
||||
</div>
|
||||
|
||||
<div className="music-studio-page-release-info-field">
|
||||
<Icons.MdTag />
|
||||
{release._id}
|
||||
</div>
|
||||
|
||||
{/* <div className="music-studio-page-release-info-field">
|
||||
<Icons.IoMdEye />
|
||||
{release.analytics?.listen_count ?? 0}
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default ReleaseItem
|
@ -1,184 +0,0 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import CoverEditor from "@components/CoverEditor"
|
||||
import { Icons } from "@components/Icons"
|
||||
import EnhancedLyricsEditor from "@components/MusicStudio/EnhancedLyricsEditor"
|
||||
|
||||
import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
const TrackEditor = (props) => {
|
||||
const context = React.useContext(ReleaseEditorStateContext)
|
||||
const [track, setTrack] = React.useState(props.track ?? {})
|
||||
|
||||
async function handleChange(key, value) {
|
||||
setTrack((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[key]: value,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openEnhancedLyricsEditor() {
|
||||
context.renderCustomPage({
|
||||
header: "Enhanced Lyrics",
|
||||
content: EnhancedLyricsEditor,
|
||||
props: {
|
||||
track: track,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleOnSave() {
|
||||
setTrack((prev) => {
|
||||
const listData = [...context.items]
|
||||
|
||||
const trackIndex = listData.findIndex(
|
||||
(item) => item.uid === prev.uid,
|
||||
)
|
||||
|
||||
if (trackIndex === -1) {
|
||||
return prev
|
||||
}
|
||||
|
||||
listData[trackIndex] = prev
|
||||
|
||||
context.setGlobalState({
|
||||
...context,
|
||||
items: listData,
|
||||
})
|
||||
|
||||
props.close()
|
||||
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
function setParentCover() {
|
||||
handleChange("cover", context.cover)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
context.setCustomPageActions([
|
||||
{
|
||||
label: "Save",
|
||||
icon: "FiSave",
|
||||
type: "primary",
|
||||
onClick: handleOnSave,
|
||||
disabled: props.track === track,
|
||||
},
|
||||
])
|
||||
}, [track])
|
||||
|
||||
return (
|
||||
<div className="track-editor">
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdImage />
|
||||
<span>Cover</span>
|
||||
</div>
|
||||
|
||||
<CoverEditor
|
||||
value={track.cover}
|
||||
onChange={(url) => handleChange("cover", url)}
|
||||
extraActions={[
|
||||
<antd.Button onClick={setParentCover}>
|
||||
Use Parent
|
||||
</antd.Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdOutlineMusicNote />
|
||||
<span>Title</span>
|
||||
</div>
|
||||
|
||||
<antd.Input
|
||||
value={track.title}
|
||||
placeholder="Track title"
|
||||
onChange={(e) => handleChange("title", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.FiUser />
|
||||
<span>Artist</span>
|
||||
</div>
|
||||
|
||||
<antd.Input
|
||||
value={track.artist}
|
||||
placeholder="Artist"
|
||||
onChange={(e) => handleChange("artist", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdAlbum />
|
||||
<span>Album</span>
|
||||
</div>
|
||||
|
||||
<antd.Input
|
||||
value={track.album}
|
||||
placeholder="Album"
|
||||
onChange={(e) => handleChange("album", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdExplicit />
|
||||
<span>Explicit</span>
|
||||
</div>
|
||||
|
||||
<antd.Switch
|
||||
checked={track.explicit}
|
||||
onChange={(value) => handleChange("explicit", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.FiEye />
|
||||
<span>Public</span>
|
||||
</div>
|
||||
|
||||
<antd.Switch
|
||||
checked={track.public}
|
||||
onChange={(value) => handleChange("public", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdLyrics />
|
||||
<span>Enhanced Lyrics</span>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field-actions">
|
||||
<antd.Button
|
||||
disabled={!track.params._id}
|
||||
onClick={openEnhancedLyricsEditor}
|
||||
>
|
||||
Edit
|
||||
</antd.Button>
|
||||
|
||||
{!track.params._id && (
|
||||
<span>
|
||||
You cannot edit Video and Lyrics without release
|
||||
first
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackEditor
|
@ -1,59 +0,0 @@
|
||||
.track-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
|
||||
gap: 20px;
|
||||
|
||||
.track-editor-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
align-self: center;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.track-editor-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: flex-start;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
.track-editor-field-header {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
gap: 7px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.track-editor-field-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
gap: 10px;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -4,56 +4,56 @@ import CountUp from "react-countup"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export default (props) => {
|
||||
const [liked, setLiked] = React.useState(props.defaultLiked ?? false)
|
||||
const [clicked, setCliked] = React.useState(false)
|
||||
const LikeButtonAction = (props) => {
|
||||
const [liked, setLiked] = React.useState(props.defaultLiked ?? false)
|
||||
const [clicked, setCliked] = React.useState(false)
|
||||
|
||||
const handleClick = async () => {
|
||||
let to = !liked
|
||||
const handleClick = async () => {
|
||||
let to = !liked
|
||||
|
||||
setCliked(to)
|
||||
setCliked(to)
|
||||
|
||||
if (typeof props.onClick === "function") {
|
||||
const result = await props.onClick(to)
|
||||
if (typeof result === "boolean") {
|
||||
to = result
|
||||
}
|
||||
}
|
||||
if (typeof props.onClick === "function") {
|
||||
const result = await props.onClick(to)
|
||||
|
||||
setLiked(to)
|
||||
}
|
||||
if (typeof result === "boolean") {
|
||||
to = result
|
||||
}
|
||||
}
|
||||
|
||||
return <div
|
||||
className={
|
||||
classnames(
|
||||
"like_btn_wrapper",
|
||||
{
|
||||
["liked"]: liked,
|
||||
["clicked"]: clicked
|
||||
}
|
||||
)
|
||||
}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<button className="like_btn">
|
||||
<div className="ripple"></div>
|
||||
<svg
|
||||
className="heart"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<CountUp
|
||||
start={props.count}
|
||||
separator="."
|
||||
end={props.count}
|
||||
startOnMount={false}
|
||||
duration={3}
|
||||
className="count"
|
||||
useEasing={true}
|
||||
/>
|
||||
</div>
|
||||
setLiked(to)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames("like_btn_wrapper", {
|
||||
["liked"]: liked,
|
||||
["clicked"]: clicked,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<button className="like_btn">
|
||||
<div className="ripple"></div>
|
||||
<svg
|
||||
className="heart"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<CountUp
|
||||
start={props.count}
|
||||
separator="."
|
||||
end={props.count}
|
||||
startOnMount={false}
|
||||
duration={3}
|
||||
className="count"
|
||||
useEasing={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LikeButtonAction
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
import lodash from "lodash"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import { Icons } from "@components/Icons"
|
||||
|
||||
@ -23,353 +24,8 @@ const LoadingComponent = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const NoResultComponent = () => {
|
||||
return (
|
||||
<antd.Empty
|
||||
description="No more post here"
|
||||
style={{
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const typeToComponent = {
|
||||
post: (args) => <PostCard {...args} />,
|
||||
//"playlist": (args) => <PlaylistTimelineEntry {...args} />,
|
||||
}
|
||||
|
||||
const Entry = React.memo((props) => {
|
||||
const { data } = props
|
||||
|
||||
return React.createElement(
|
||||
typeToComponent[data.type ?? "post"] ?? PostCard,
|
||||
{
|
||||
key: data._id,
|
||||
data: data,
|
||||
disableReplyTag: props.disableReplyTag,
|
||||
disableHasReplies: props.disableHasReplies,
|
||||
events: {
|
||||
onClickLike: props.onLikePost,
|
||||
onClickSave: props.onSavePost,
|
||||
onClickDelete: props.onDeletePost,
|
||||
onClickEdit: props.onEditPost,
|
||||
onClickReply: props.onReplyPost,
|
||||
onDoubleClick: props.onDoubleClick,
|
||||
},
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const PostList = React.forwardRef((props, ref) => {
|
||||
return (
|
||||
<LoadMore
|
||||
ref={ref}
|
||||
className="post-list"
|
||||
loadingComponent={LoadingComponent}
|
||||
noResultComponent={NoResultComponent}
|
||||
hasMore={props.hasMore}
|
||||
fetching={props.loading}
|
||||
onBottom={props.onLoadMore}
|
||||
>
|
||||
{!props.realtimeUpdates && !app.isMobile && (
|
||||
<div className="resume_btn_wrapper">
|
||||
<antd.Button
|
||||
type="primary"
|
||||
shape="round"
|
||||
onClick={props.onResumeRealtimeUpdates}
|
||||
loading={props.resumingLoading}
|
||||
icon={<Icons.FiSyncOutlined />}
|
||||
>
|
||||
Resume
|
||||
</antd.Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{props.list.map((data) => {
|
||||
return <Entry key={data._id} data={data} {...props} />
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</LoadMore>
|
||||
)
|
||||
})
|
||||
|
||||
export class PostsListsComponent extends React.Component {
|
||||
state = {
|
||||
openPost: null,
|
||||
|
||||
loading: false,
|
||||
resumingLoading: false,
|
||||
initialLoading: true,
|
||||
scrollingToTop: false,
|
||||
|
||||
topVisible: true,
|
||||
|
||||
realtimeUpdates: true,
|
||||
|
||||
hasMore: true,
|
||||
list: this.props.list ?? [],
|
||||
pageCount: 0,
|
||||
}
|
||||
|
||||
parentRef = this.props.innerRef
|
||||
listRef = React.createRef()
|
||||
|
||||
timelineWsEvents = {
|
||||
"post:new": (data) => {
|
||||
console.log("[WS] Recived a post >", data)
|
||||
|
||||
this.setState({
|
||||
list: [data, ...this.state.list],
|
||||
})
|
||||
},
|
||||
"post:delete": (id) => {
|
||||
console.log("[WS] Received a post delete >", id)
|
||||
|
||||
this.setState({
|
||||
list: this.state.list.filter((post) => {
|
||||
return post._id !== id
|
||||
}),
|
||||
})
|
||||
},
|
||||
"post:update": (data) => {
|
||||
console.log("[WS] Received a post update >", data)
|
||||
|
||||
this.setState({
|
||||
list: this.state.list.map((post) => {
|
||||
if (post._id === data._id) {
|
||||
return data
|
||||
}
|
||||
|
||||
return post
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
handleLoad = async (fn, params = {}) => {
|
||||
if (this.state.loading === true) {
|
||||
console.warn(`Please wait to load the post before load more`)
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
})
|
||||
|
||||
let payload = {
|
||||
page: this.state.pageCount,
|
||||
limit: app.cores.settings.get("feed_max_fetch"),
|
||||
}
|
||||
|
||||
if (this.props.loadFromModelProps) {
|
||||
payload = {
|
||||
...payload,
|
||||
...this.props.loadFromModelProps,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await fn(payload).catch((err) => {
|
||||
console.error(err)
|
||||
|
||||
app.message.error("Failed to load more posts")
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
if (result) {
|
||||
if (result.length === 0) {
|
||||
return this.setState({
|
||||
hasMore: false,
|
||||
})
|
||||
}
|
||||
|
||||
if (params.replace) {
|
||||
this.setState({
|
||||
list: result,
|
||||
pageCount: 0,
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
list: [...this.state.list, ...result],
|
||||
pageCount: this.state.pageCount + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: false,
|
||||
})
|
||||
}
|
||||
|
||||
addPost = (post) => {
|
||||
this.setState({
|
||||
list: [post, ...this.state.list],
|
||||
})
|
||||
}
|
||||
|
||||
removePost = (id) => {
|
||||
this.setState({
|
||||
list: this.state.list.filter((post) => {
|
||||
return post._id !== id
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
_hacks = {
|
||||
addPost: this.addPost,
|
||||
removePost: this.removePost,
|
||||
addRandomPost: () => {
|
||||
const randomId = Math.random().toString(36).substring(7)
|
||||
|
||||
this.addPost({
|
||||
_id: randomId,
|
||||
message: `Random post ${randomId}`,
|
||||
user: {
|
||||
_id: randomId,
|
||||
username: "random user",
|
||||
avatar: `https://api.dicebear.com/7.x/thumbs/svg?seed=${randomId}`,
|
||||
},
|
||||
})
|
||||
},
|
||||
listRef: this.listRef,
|
||||
}
|
||||
|
||||
onResumeRealtimeUpdates = async () => {
|
||||
console.log("Resuming realtime updates")
|
||||
|
||||
this.setState({
|
||||
resumingLoading: true,
|
||||
scrollingToTop: true,
|
||||
})
|
||||
|
||||
this.listRef.current.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
})
|
||||
|
||||
// reload posts
|
||||
await this.handleLoad(this.props.loadFromModel, {
|
||||
replace: true,
|
||||
})
|
||||
|
||||
this.setState({
|
||||
realtimeUpdates: true,
|
||||
resumingLoading: false,
|
||||
})
|
||||
}
|
||||
|
||||
onScrollList = (e) => {
|
||||
const { scrollTop } = e.target
|
||||
|
||||
if (this.state.scrollingToTop && scrollTop === 0) {
|
||||
this.setState({
|
||||
scrollingToTop: false,
|
||||
})
|
||||
}
|
||||
|
||||
if (scrollTop > 200) {
|
||||
if (this.state.topVisible) {
|
||||
this.setState({
|
||||
topVisible: false,
|
||||
})
|
||||
|
||||
if (typeof this.props.onTopVisibility === "function") {
|
||||
this.props.onTopVisibility(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!this.props.realtime ||
|
||||
this.state.resumingLoading ||
|
||||
this.state.scrollingToTop
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.setState({
|
||||
realtimeUpdates: false,
|
||||
})
|
||||
} else {
|
||||
if (!this.state.topVisible) {
|
||||
this.setState({
|
||||
topVisible: true,
|
||||
})
|
||||
|
||||
if (typeof this.props.onTopVisibility === "function") {
|
||||
this.props.onTopVisibility(true)
|
||||
}
|
||||
|
||||
// if (this.props.realtime || !this.state.realtimeUpdates && !this.state.resumingLoading && scrollTop < 5) {
|
||||
// this.onResumeRealtimeUpdates()
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount = async () => {
|
||||
if (typeof this.props.loadFromModel === "function") {
|
||||
await this.handleLoad(this.props.loadFromModel)
|
||||
}
|
||||
|
||||
this.setState({
|
||||
initialLoading: false,
|
||||
})
|
||||
|
||||
if (this.props.realtime) {
|
||||
for (const [event, handler] of Object.entries(
|
||||
this.timelineWsEvents,
|
||||
)) {
|
||||
app.cores.api.listenEvent(event, handler, "posts")
|
||||
}
|
||||
|
||||
app.cores.api.joinTopic(
|
||||
"posts",
|
||||
this.props.customTopic ?? "realtime:feed",
|
||||
)
|
||||
}
|
||||
|
||||
if (this.listRef && this.listRef.current) {
|
||||
this.listRef.current.addEventListener("scroll", this.onScrollList)
|
||||
}
|
||||
|
||||
window._hacks = this._hacks
|
||||
}
|
||||
|
||||
componentWillUnmount = async () => {
|
||||
if (this.props.realtime) {
|
||||
for (const [event, handler] of Object.entries(
|
||||
this.timelineWsEvents,
|
||||
)) {
|
||||
app.cores.api.unlistenEvent(event, handler, "posts")
|
||||
}
|
||||
|
||||
app.cores.api.leaveTopic(
|
||||
"posts",
|
||||
this.props.customTopic ?? "realtime:feed",
|
||||
)
|
||||
}
|
||||
|
||||
if (this.listRef && this.listRef.current) {
|
||||
this.listRef.current.removeEventListener(
|
||||
"scroll",
|
||||
this.onScrollList,
|
||||
)
|
||||
}
|
||||
|
||||
window._hacks = null
|
||||
}
|
||||
|
||||
componentDidUpdate = async (prevProps, prevState) => {
|
||||
if (prevProps.list !== this.props.list) {
|
||||
this.setState({
|
||||
list: this.props.list,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onLikePost = async (data) => {
|
||||
const PostActions = {
|
||||
onClickLike: async (data) => {
|
||||
let result = await PostModel.toggleLike({ post_id: data._id }).catch(
|
||||
() => {
|
||||
antd.message.error("Failed to like post")
|
||||
@ -379,9 +35,8 @@ export class PostsListsComponent extends React.Component {
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
onSavePost = async (data) => {
|
||||
},
|
||||
onClickSave: async (data) => {
|
||||
let result = await PostModel.toggleSave({ post_id: data._id }).catch(
|
||||
() => {
|
||||
antd.message.error("Failed to save post")
|
||||
@ -391,25 +46,8 @@ export class PostsListsComponent extends React.Component {
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
onEditPost = (data) => {
|
||||
app.controls.openPostCreator({
|
||||
edit_post: data._id,
|
||||
})
|
||||
}
|
||||
|
||||
onReplyPost = (data) => {
|
||||
app.controls.openPostCreator({
|
||||
reply_to: data._id,
|
||||
})
|
||||
}
|
||||
|
||||
onDoubleClickPost = (data) => {
|
||||
app.navigation.goToPost(data._id)
|
||||
}
|
||||
|
||||
onDeletePost = async (data) => {
|
||||
},
|
||||
onClickDelete: async (data) => {
|
||||
antd.Modal.confirm({
|
||||
title: "Are you sure you want to delete this post?",
|
||||
content: "This action is irreversible",
|
||||
@ -422,74 +60,218 @@ export class PostsListsComponent extends React.Component {
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
onClickEdit: async (data) => {
|
||||
app.controls.openPostCreator({
|
||||
edit_post: data._id,
|
||||
})
|
||||
},
|
||||
onClickReply: async (data) => {
|
||||
app.controls.openPostCreator({
|
||||
reply_to: data._id,
|
||||
})
|
||||
},
|
||||
onDoubleClick: async (data) => {
|
||||
app.navigation.goToPost(data._id)
|
||||
},
|
||||
}
|
||||
|
||||
ontoggleOpen = (to, data) => {
|
||||
if (typeof this.props.onOpenPost === "function") {
|
||||
this.props.onOpenPost(to, data)
|
||||
const Entry = (props) => {
|
||||
const { data } = props
|
||||
|
||||
return (
|
||||
<PostCard
|
||||
key={data._id}
|
||||
data={data}
|
||||
disableReplyTag={props.disableReplyTag}
|
||||
disableHasReplies={props.disableHasReplies}
|
||||
events={PostActions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const PostList = React.forwardRef((props, ref) => {
|
||||
return (
|
||||
<LoadMore
|
||||
ref={ref}
|
||||
className="post-list"
|
||||
loadingComponent={LoadingComponent}
|
||||
hasMore={props.hasMore}
|
||||
onBottom={props.onLoadMore}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{props.list.map((data) => {
|
||||
return <Entry key={data._id} data={data} {...props} />
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</LoadMore>
|
||||
)
|
||||
})
|
||||
|
||||
const PostsListsComponent = (props) => {
|
||||
const [list, setList] = React.useState([])
|
||||
const [hasMore, setHasMore] = React.useState(true)
|
||||
|
||||
// Refs
|
||||
const firstLoad = React.useRef(true)
|
||||
const loading = React.useRef(false)
|
||||
const page = React.useRef(0)
|
||||
const listRef = React.useRef(null)
|
||||
const loadModelPropsRef = React.useRef({})
|
||||
|
||||
const timelineWsEvents = React.useRef({
|
||||
"post:new": (data) => {
|
||||
console.debug("post:new", data)
|
||||
|
||||
setList((prev) => {
|
||||
return [data, ...prev]
|
||||
})
|
||||
},
|
||||
"post:delete": (data) => {
|
||||
console.debug("post:delete", data)
|
||||
|
||||
setList((prev) => {
|
||||
return prev.filter((item) => {
|
||||
return item._id !== data._id
|
||||
})
|
||||
})
|
||||
},
|
||||
"post:update": (data) => {
|
||||
console.debug("post:update", data)
|
||||
|
||||
setList((prev) => {
|
||||
return prev.map((item) => {
|
||||
if (item._id === data._id) {
|
||||
return data
|
||||
}
|
||||
return item
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Logic
|
||||
async function handleLoad(fn, params = {}) {
|
||||
if (loading.current === true) {
|
||||
console.warn(`Please wait to load the post before load more`)
|
||||
return
|
||||
}
|
||||
|
||||
loading.current = true
|
||||
|
||||
let payload = {
|
||||
page: page.current,
|
||||
limit: app.cores.settings.get("feed_max_fetch"),
|
||||
}
|
||||
|
||||
if (loadModelPropsRef.current) {
|
||||
payload = {
|
||||
...payload,
|
||||
...loadModelPropsRef.current,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await fn(payload).catch((err) => {
|
||||
console.error(err)
|
||||
app.message.error("Failed to load more posts")
|
||||
return null
|
||||
})
|
||||
|
||||
loading.current = false
|
||||
firstLoad.current = false
|
||||
|
||||
if (result) {
|
||||
setHasMore(result.has_more)
|
||||
|
||||
if (result.items?.length > 0) {
|
||||
if (params.replace) {
|
||||
setList(result.items)
|
||||
page.current = 0
|
||||
} else {
|
||||
setList((prev) => {
|
||||
return [...prev, ...result.items]
|
||||
})
|
||||
page.current = page.current + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onLoadMore = async () => {
|
||||
if (typeof this.props.onLoadMore === "function") {
|
||||
return this.handleLoad(this.props.onLoadMore)
|
||||
} else if (this.props.loadFromModel) {
|
||||
return this.handleLoad(this.props.loadFromModel)
|
||||
const onLoadMore = React.useCallback(() => {
|
||||
if (typeof props.onLoadMore === "function") {
|
||||
return handleLoad(props.onLoadMore)
|
||||
} else if (props.loadFromModel) {
|
||||
return handleLoad(props.loadFromModel)
|
||||
}
|
||||
}
|
||||
}, [props])
|
||||
|
||||
render() {
|
||||
if (this.state.initialLoading) {
|
||||
return <antd.Skeleton active />
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!lodash.isEqual(props.loadFromModelProps, loadModelPropsRef.current)
|
||||
) {
|
||||
loadModelPropsRef.current = props.loadFromModelProps
|
||||
|
||||
page.current = 0
|
||||
loading.current = false
|
||||
|
||||
setHasMore(true)
|
||||
setList([])
|
||||
handleLoad(props.loadFromModel)
|
||||
}
|
||||
}, [
|
||||
props.loadFromModel,
|
||||
props.loadFromModelProps,
|
||||
firstLoad.current === false,
|
||||
])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.loadFromModelProps) {
|
||||
loadModelPropsRef.current = props.loadFromModelProps
|
||||
}
|
||||
|
||||
if (this.state.list.length === 0) {
|
||||
if (typeof this.props.emptyListRender === "function") {
|
||||
return React.createElement(this.props.emptyListRender)
|
||||
if (typeof props.loadFromModel === "function") {
|
||||
handleLoad(props.loadFromModel)
|
||||
}
|
||||
|
||||
if (props.realtime) {
|
||||
for (const [event, handler] of Object.entries(
|
||||
timelineWsEvents.current,
|
||||
)) {
|
||||
app.cores.api.listenEvent(event, handler, "posts")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="no_more_posts">
|
||||
<antd.Empty />
|
||||
<h1>Whoa, nothing on here...</h1>
|
||||
</div>
|
||||
app.cores.api.joinTopic(
|
||||
"posts",
|
||||
props.customTopic ?? "realtime:feed",
|
||||
)
|
||||
}
|
||||
|
||||
const PostListProps = {
|
||||
list: this.state.list,
|
||||
return () => {
|
||||
if (props.realtime) {
|
||||
for (const [event, handler] of Object.entries(
|
||||
timelineWsEvents.current,
|
||||
)) {
|
||||
app.cores.api.unlistenEvent(event, handler, "posts")
|
||||
}
|
||||
|
||||
disableReplyTag: this.props.disableReplyTag,
|
||||
disableHasReplies: this.props.disableHasReplies,
|
||||
|
||||
onLikePost: this.onLikePost,
|
||||
onSavePost: this.onSavePost,
|
||||
onDeletePost: this.onDeletePost,
|
||||
onEditPost: this.onEditPost,
|
||||
onReplyPost: this.onReplyPost,
|
||||
onDoubleClick: this.onDoubleClickPost,
|
||||
|
||||
onLoadMore: this.onLoadMore,
|
||||
hasMore: this.state.hasMore,
|
||||
loading: this.state.loading,
|
||||
|
||||
realtimeUpdates: this.state.realtimeUpdates,
|
||||
resumingLoading: this.state.resumingLoading,
|
||||
onResumeRealtimeUpdates: this.onResumeRealtimeUpdates,
|
||||
app.cores.api.leaveTopic(
|
||||
"posts",
|
||||
props.customTopic ?? "realtime:feed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (app.isMobile) {
|
||||
return <PostList ref={this.listRef} {...PostListProps} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="post-list_wrapper">
|
||||
<PostList ref={this.listRef} {...PostListProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="post-list_wrapper">
|
||||
<PostList
|
||||
ref={listRef}
|
||||
list={list}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={onLoadMore}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef((props, ref) => (
|
||||
<PostsListsComponent innerRef={ref} {...props} />
|
||||
))
|
||||
export default PostsListsComponent
|
||||
|
@ -10,256 +10,223 @@ import linksDecorators from "@config/linksDecorators"
|
||||
import "./index.less"
|
||||
|
||||
function processValue(value, decorator) {
|
||||
if (decorator.hrefResolve) {
|
||||
if (!String(value).includes(decorator.hrefResolve)) {
|
||||
return `${decorator.hrefResolve}${value}`
|
||||
}
|
||||
}
|
||||
if (decorator.hrefResolve) {
|
||||
if (!String(value).includes(decorator.hrefResolve)) {
|
||||
return `${decorator.hrefResolve}${value}`
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
return value
|
||||
}
|
||||
|
||||
const UserLinkViewer = (props) => {
|
||||
const { link, decorator } = props
|
||||
const { link, decorator } = props
|
||||
|
||||
return <div className="userLinkViewer">
|
||||
<div className="userLinkViewer_icon">
|
||||
{
|
||||
createIconRender(decorator.icon ?? "MdLink")
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="userLinkViewer_value">
|
||||
<p>
|
||||
{
|
||||
link.value
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="userLinkViewer">
|
||||
<div className="userLinkViewer_icon">
|
||||
{createIconRender(decorator.icon ?? "MdLink")}
|
||||
</div>
|
||||
|
||||
<div className="userLinkViewer_value">
|
||||
<p>{link.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserLink = (props) => {
|
||||
let { index, link } = props
|
||||
let { index, link } = props
|
||||
|
||||
link.key = link.key.toLowerCase()
|
||||
link.key = link.key.toLowerCase()
|
||||
|
||||
const decorator = linksDecorators[link.key] ?? {}
|
||||
const decorator = linksDecorators[link.key] ?? {}
|
||||
|
||||
link.value = processValue(link.value, decorator)
|
||||
link.value = processValue(link.value, decorator)
|
||||
|
||||
const hasHref = String(link.value).includes("://")
|
||||
const hasHref = String(link.value).includes("://")
|
||||
|
||||
const handleOnClick = () => {
|
||||
if (!hasHref) {
|
||||
if (app.isMobile) {
|
||||
app.layout.drawer.open("link_viewer", UserLinkViewer, {
|
||||
componentProps: {
|
||||
link: link,
|
||||
decorator: decorator
|
||||
}
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
const handleOnClick = () => {
|
||||
if (!hasHref) {
|
||||
if (app.isMobile) {
|
||||
app.layout.drawer.open("link_viewer", UserLinkViewer, {
|
||||
componentProps: {
|
||||
link: link,
|
||||
decorator: decorator,
|
||||
},
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
window.open(link.value, "_blank")
|
||||
}
|
||||
window.open(link.value, "_blank")
|
||||
}
|
||||
|
||||
const renderName = () => {
|
||||
if (decorator.hrefResolve) {
|
||||
return decorator.label ?? link.value
|
||||
}
|
||||
const renderName = () => {
|
||||
if (decorator.hrefResolve) {
|
||||
return decorator.label ?? link.value
|
||||
}
|
||||
|
||||
return link.value
|
||||
}
|
||||
return link.value
|
||||
}
|
||||
|
||||
return <div
|
||||
key={index}
|
||||
id={`link-${index}-${link.key}`}
|
||||
className={`userLink ${hasHref ? "clickable" : ""}`}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{
|
||||
createIconRender(decorator.icon ?? "MdLink")
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
id={`link-${index}-${link.key}`}
|
||||
className={`userLink ${hasHref ? "clickable" : ""}`}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{createIconRender(decorator.icon ?? "MdLink")}
|
||||
|
||||
{
|
||||
!app.isMobile && <p>
|
||||
{
|
||||
renderName()
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
{!app.isMobile && <p>{renderName()}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const UserCard = React.forwardRef((props, ref) => {
|
||||
const [user, setUser] = React.useState(props.user)
|
||||
export const UserCard = (props) => {
|
||||
const [user, setUser] = React.useState(props.user)
|
||||
|
||||
// TODO: Support API user data fetching
|
||||
React.useEffect(() => {
|
||||
setUser(props.user)
|
||||
}, [props.user])
|
||||
|
||||
return <div
|
||||
className="userCard"
|
||||
ref={ref}
|
||||
>
|
||||
<div className="avatar">
|
||||
<Image
|
||||
src={user.avatar}
|
||||
/>
|
||||
</div>
|
||||
// TODO: Support API user data fetching
|
||||
|
||||
<div className="username">
|
||||
<div className="username_text">
|
||||
<h1>
|
||||
{user.public_name || user.username}
|
||||
{user.verified && <Icons.verifiedBadge />}
|
||||
</h1>
|
||||
<span>
|
||||
@{user.username}
|
||||
</span>
|
||||
</div>
|
||||
return (
|
||||
<div className="userCard">
|
||||
<div className="avatar">
|
||||
<Image src={user.avatar} />
|
||||
</div>
|
||||
|
||||
{
|
||||
user.badges?.length > 0 && <UserBadges user_id={user._id} />
|
||||
}
|
||||
</div>
|
||||
<div className="username">
|
||||
<div className="username_text">
|
||||
<h1>
|
||||
{user.public_name || user.username}
|
||||
{user.verified && <Icons.verifiedBadge />}
|
||||
</h1>
|
||||
<span>@{user.username}</span>
|
||||
</div>
|
||||
|
||||
<div className="description">
|
||||
<span>
|
||||
{user.description}
|
||||
</span>
|
||||
</div>
|
||||
{user.badges?.length > 0 && <UserBadges user_id={user._id} />}
|
||||
</div>
|
||||
|
||||
{
|
||||
user.links && Array.isArray(user.links) && user.links.length > 0 && <div className="userLinks">
|
||||
{
|
||||
user.links.map((link, index) => {
|
||||
return <UserLink index={index} link={link} />
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
})
|
||||
<div className="description">
|
||||
<span>{user.description}</span>
|
||||
</div>
|
||||
|
||||
export const MobileUserCard = React.forwardRef((props, ref) => {
|
||||
return <div
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
"_mobile_userCard",
|
||||
{
|
||||
["no-cover"]: !props.user.cover
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="_mobile_userCard_top">
|
||||
{
|
||||
props.user.cover && <div className="_mobile_userCard_top_cover">
|
||||
<div
|
||||
className="cover"
|
||||
style={{
|
||||
backgroundImage: `url("${props.user.cover}")`
|
||||
}}
|
||||
/>
|
||||
{user.links &&
|
||||
Array.isArray(user.links) &&
|
||||
user.links.length > 0 && (
|
||||
<div className="userLinks">
|
||||
{user.links.map((link, index) => {
|
||||
return (
|
||||
<UserLink
|
||||
key={index}
|
||||
index={index}
|
||||
link={link}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="_mobile_userCard_top_avatar_wrapper">
|
||||
<div className="_mobile_userCard_top_avatar">
|
||||
<Image
|
||||
src={props.user.avatar}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
export const MobileUserCard = (props, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classnames("_mobile_userCard", {
|
||||
["no-cover"]: !props.user.cover,
|
||||
})}
|
||||
>
|
||||
<div className="_mobile_userCard_top">
|
||||
{props.user.cover && (
|
||||
<div className="_mobile_userCard_top_cover">
|
||||
<div
|
||||
className="cover"
|
||||
style={{
|
||||
backgroundImage: `url("${props.user.cover}")`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{
|
||||
!props.user.cover && <div className="_mobile_userCard_top_avatar">
|
||||
<Image
|
||||
src={props.user.avatar}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div className="_mobile_userCard_top_avatar_wrapper">
|
||||
<div className="_mobile_userCard_top_avatar">
|
||||
<Image src={props.user.avatar} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="_mobile_userCard_top_texts">
|
||||
<div className="_mobile_userCard_top_username">
|
||||
<h1>
|
||||
{
|
||||
props.user.fullName ?? `@${props.user.username}`
|
||||
}
|
||||
{
|
||||
props.user.verified && <Icons.verifiedBadge id="verification_tick" />
|
||||
}
|
||||
</h1>
|
||||
{!props.user.cover && (
|
||||
<div className="_mobile_userCard_top_avatar">
|
||||
<Image src={props.user.avatar} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{
|
||||
props.user.fullName && <span>
|
||||
@{props.user.username}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div className="_mobile_userCard_top_texts">
|
||||
<div className="_mobile_userCard_top_username">
|
||||
<h1>
|
||||
{props.user.fullName ?? `@${props.user.username}`}
|
||||
{props.user.verified && (
|
||||
<Icons.verifiedBadge id="verification_tick" />
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<div className="_mobile_userCard_top_badges_wrapper">
|
||||
{
|
||||
props.user.badges?.length > 0 && <UserBadges user_id={props.user._id} />
|
||||
}
|
||||
</div>
|
||||
{props.user.fullName && (
|
||||
<span>@{props.user.username}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="_mobile_userCard_top_description">
|
||||
<p>
|
||||
{
|
||||
props.user.description
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="_mobile_userCard_top_badges_wrapper">
|
||||
{props.user.badges?.length > 0 && (
|
||||
<UserBadges user_id={props.user._id} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{
|
||||
props.user.links
|
||||
&& Array.isArray(props.user.links)
|
||||
&& props.user.links.length > 0
|
||||
&& <div
|
||||
className={classnames(
|
||||
"_mobile_userCard_links",
|
||||
)}
|
||||
>
|
||||
{
|
||||
props.user.links.map((link, index) => {
|
||||
return <UserLink index={index} link={link} />
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className="_mobile_userCard_top_description">
|
||||
<p>{props.user.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classnames(
|
||||
"_mobile_card",
|
||||
"_mobile_userCard_actions",
|
||||
)}
|
||||
>
|
||||
{
|
||||
props.followers && <FollowButton
|
||||
count={props.followers.length}
|
||||
onClick={props.onClickFollow}
|
||||
followed={props.isFollowed}
|
||||
self={props.isSelf}
|
||||
/>
|
||||
}
|
||||
{props.user.links &&
|
||||
Array.isArray(props.user.links) &&
|
||||
props.user.links.length > 0 && (
|
||||
<div className={classnames("_mobile_userCard_links")}>
|
||||
{props.user.links.map((link, index) => {
|
||||
return <UserLink index={index} link={link} />
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<antd.Button
|
||||
type="primary"
|
||||
icon={<Icons.MdMessage />}
|
||||
disabled
|
||||
/>
|
||||
<div
|
||||
className={classnames(
|
||||
"_mobile_card",
|
||||
"_mobile_userCard_actions",
|
||||
)}
|
||||
>
|
||||
{props.followers && (
|
||||
<FollowButton
|
||||
count={props.followers.length}
|
||||
onClick={props.onClickFollow}
|
||||
followed={props.isFollowed}
|
||||
self={props.isSelf}
|
||||
/>
|
||||
)}
|
||||
|
||||
<antd.Button
|
||||
type="primary"
|
||||
icon={<Icons.MdShare />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
})
|
||||
<antd.Button
|
||||
type="primary"
|
||||
icon={<Icons.MdMessage />}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<antd.Button type="primary" icon={<Icons.MdShare />} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserCard
|
@ -59,6 +59,7 @@ const UserPreview = (props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
id={userData._id}
|
||||
className={classnames("userPreview", {
|
||||
["clickable"]: typeof props.onClick === "function",
|
||||
["small"]: props.small && !props.big,
|
||||
|
@ -65,8 +65,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
transform: translate(0, 3px);
|
||||
|
||||
img {
|
||||
object-fit: cover;
|
||||
|
||||
|
@ -1,19 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
export const DefaultReleaseEditorState = {
|
||||
cover: null,
|
||||
title: "Untitled",
|
||||
type: "single",
|
||||
public: false,
|
||||
|
||||
items: [],
|
||||
pendingUploads: [],
|
||||
|
||||
setCustomPage: () => {},
|
||||
}
|
||||
|
||||
export const ReleaseEditorStateContext = React.createContext(
|
||||
DefaultReleaseEditorState,
|
||||
)
|
||||
|
||||
export default ReleaseEditorStateContext
|
@ -56,7 +56,9 @@ export class WithPlayerContext extends React.Component {
|
||||
|
||||
events = {
|
||||
"player.state.update": async (state) => {
|
||||
this.setState(state)
|
||||
if (state !== this.state) {
|
||||
this.setState(state)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
import shaka from "shaka-player/dist/shaka-player.compiled.js"
|
||||
import * as dashjs from "dashjs"
|
||||
|
||||
import MPDParser from "../mpd_parser"
|
||||
|
||||
import PlayerProcessors from "./PlayerProcessors"
|
||||
import AudioPlayerStorage from "../player.storage"
|
||||
import TrackManifest from "../classes/TrackManifest"
|
||||
|
||||
import findInitializationChunk from "../helpers/findInitializationChunk"
|
||||
import parseSourceFormatMetadata from "../helpers/parseSourceFormatMetadata"
|
||||
import handleInlineDashManifest from "../helpers/handleInlineDashManifest"
|
||||
|
||||
export default class AudioBase {
|
||||
constructor(player) {
|
||||
@ -45,14 +45,14 @@ export default class AudioBase {
|
||||
this.audio.addEventListener(key, value)
|
||||
}
|
||||
|
||||
// setup shaka player for mpd
|
||||
// setup dash.js player for mpd
|
||||
this.createDemuxer()
|
||||
|
||||
// create element source with low latency buffer
|
||||
this.elementSource = this.context.createMediaElementSource(this.audio)
|
||||
|
||||
await this.processorsManager.initialize(),
|
||||
await this.processorsManager.attachAllNodes()
|
||||
await this.processorsManager.initialize()
|
||||
await this.processorsManager.attachAllNodes()
|
||||
}
|
||||
|
||||
itemInit = async (manifest) => {
|
||||
@ -60,29 +60,49 @@ export default class AudioBase {
|
||||
return null
|
||||
}
|
||||
|
||||
if (manifest._initialized) {
|
||||
return manifest
|
||||
}
|
||||
|
||||
this.console.time("itemInit()")
|
||||
|
||||
if (
|
||||
typeof manifest === "string" ||
|
||||
(!manifest.source && !manifest.dash_manifest)
|
||||
) {
|
||||
this.console.time("resolve")
|
||||
this.console.time("resolve manifest")
|
||||
manifest = await this.player.serviceProviders.resolve(manifest)
|
||||
this.console.timeEnd("resolve")
|
||||
this.console.timeEnd("resolve manifest")
|
||||
}
|
||||
|
||||
if (!(manifest instanceof TrackManifest)) {
|
||||
this.console.time("init manifest")
|
||||
this.console.time("instanciate class")
|
||||
manifest = new TrackManifest(manifest, this.player)
|
||||
this.console.timeEnd("init manifest")
|
||||
this.console.timeEnd("instanciate class")
|
||||
}
|
||||
|
||||
if (manifest.mpd_mode === true && !manifest.dash_manifest) {
|
||||
this.console.time("fetch dash manifest")
|
||||
manifest.dash_manifest = await fetch(manifest.source).then((r) =>
|
||||
r.text(),
|
||||
if (
|
||||
manifest.mpd_mode === true &&
|
||||
!manifest.dash_manifest &&
|
||||
this.demuxer
|
||||
) {
|
||||
this.console.time("fetch")
|
||||
const manifestString = await fetch(manifest.source).then((res) =>
|
||||
res.text(),
|
||||
)
|
||||
this.console.timeEnd("fetch dash manifest")
|
||||
this.console.timeEnd("fetch")
|
||||
|
||||
this.console.time("parse mpd")
|
||||
manifest.dash_manifest = await MPDParser(
|
||||
manifestString,
|
||||
manifest.source,
|
||||
)
|
||||
this.console.timeEnd("parse mpd")
|
||||
}
|
||||
|
||||
manifest._initialized = true
|
||||
this.console.timeEnd("itemInit()")
|
||||
|
||||
return manifest
|
||||
}
|
||||
|
||||
@ -110,28 +130,32 @@ export default class AudioBase {
|
||||
this.processors.gain.set(this.player.state.volume)
|
||||
}
|
||||
|
||||
if (this.audio.paused) {
|
||||
try {
|
||||
this.console.time("play")
|
||||
await this.audio.play()
|
||||
this.console.timeEnd("play")
|
||||
} catch (error) {
|
||||
this.console.error(
|
||||
"Error during audio.play():",
|
||||
error,
|
||||
"State:",
|
||||
this.audio.readyState,
|
||||
)
|
||||
}
|
||||
if (manifest.mpd_mode && this.demuxer) {
|
||||
this.console.time("play")
|
||||
await this.demuxer.play()
|
||||
this.console.timeEnd("play")
|
||||
}
|
||||
|
||||
if (!manifest.mpd_mode && this.audio.paused) {
|
||||
this.console.time("play")
|
||||
await this.audio.play()
|
||||
this.console.timeEnd("play")
|
||||
}
|
||||
|
||||
let initChunk = manifest.source
|
||||
|
||||
if (this.demuxer && manifest.dash_manifest) {
|
||||
initChunk = findInitializationChunk(
|
||||
manifest.source,
|
||||
manifest.dash_manifest,
|
||||
let initializationTemplate =
|
||||
manifest.dash_manifest["Period"][0]["AdaptationSet"][0][
|
||||
"Representation"
|
||||
][0]["SegmentTemplate"]["initialization"]
|
||||
|
||||
initializationTemplate = initializationTemplate.replace(
|
||||
"$RepresentationID$",
|
||||
"0",
|
||||
)
|
||||
|
||||
initChunk = new URL(initializationTemplate, manifest.source)
|
||||
}
|
||||
|
||||
try {
|
||||
@ -172,79 +196,67 @@ export default class AudioBase {
|
||||
|
||||
if (!this.demuxer) {
|
||||
this.console.log("Creating demuxer cause not initialized")
|
||||
this.createDemuxer()
|
||||
await this.createDemuxer()
|
||||
}
|
||||
|
||||
if (manifest._preloaded) {
|
||||
this.console.log(
|
||||
`using preloaded source >`,
|
||||
manifest._preloaded,
|
||||
)
|
||||
await this.demuxer.attachSource(manifest.dash_manifest, 0)
|
||||
|
||||
return await this.demuxer.load(manifest._preloaded)
|
||||
}
|
||||
|
||||
const inlineManifest =
|
||||
"inline://" + manifest.source + "::" + manifest.dash_manifest
|
||||
|
||||
return await this.demuxer
|
||||
.load(inlineManifest, 0, "application/dash+xml")
|
||||
.catch((err) => {
|
||||
this.console.error("Error loading inline manifest", err)
|
||||
})
|
||||
return manifest.source
|
||||
}
|
||||
|
||||
// if not using demuxer, destroy previous instance
|
||||
if (this.demuxer) {
|
||||
await this.demuxer.unload()
|
||||
await this.demuxer.destroy()
|
||||
try {
|
||||
this.demuxer.reset()
|
||||
this.demuxer.destroy()
|
||||
} catch (error) {
|
||||
this.console.warn("Error destroying demuxer:", error)
|
||||
}
|
||||
|
||||
this.demuxer = null
|
||||
}
|
||||
|
||||
// load source
|
||||
this.audio.src = manifest.source
|
||||
return this.audio.load()
|
||||
this.audio.load()
|
||||
|
||||
return manifest.source
|
||||
}
|
||||
|
||||
async createDemuxer() {
|
||||
// Destroy previous instance if exists
|
||||
if (this.demuxer) {
|
||||
await this.demuxer.unload()
|
||||
await this.demuxer.detach()
|
||||
await this.demuxer.destroy()
|
||||
try {
|
||||
this.demuxer.reset()
|
||||
this.demuxer.destroy()
|
||||
} catch (error) {
|
||||
this.console.warn("Error destroying previous demuxer:", error)
|
||||
}
|
||||
}
|
||||
|
||||
this.demuxer = new shaka.Player()
|
||||
this.demuxer = dashjs.MediaPlayer().create()
|
||||
|
||||
this.demuxer.attach(this.audio)
|
||||
try {
|
||||
this.demuxer.initialize(this.audio)
|
||||
} catch (error) {
|
||||
this.console.error("Error initializing DASH.js player:", error)
|
||||
throw error
|
||||
}
|
||||
|
||||
this.demuxer.configure({
|
||||
manifest: {
|
||||
//updatePeriod: 5,
|
||||
disableVideo: true,
|
||||
disableText: true,
|
||||
dash: {
|
||||
ignoreMinBufferTime: true,
|
||||
ignoreMaxSegmentDuration: true,
|
||||
autoCorrectDrift: false,
|
||||
enableFastSwitching: true,
|
||||
useStreamOnceInPeriodFlattening: false,
|
||||
},
|
||||
},
|
||||
this.demuxer.updateSettings({
|
||||
streaming: {
|
||||
bufferingGoal: 15,
|
||||
rebufferingGoal: 1,
|
||||
bufferBehind: 30,
|
||||
stallThreshold: 0.5,
|
||||
//cacheInitSegments: true,
|
||||
buffer: {
|
||||
bufferTimeAtTopQuality: 15,
|
||||
initialBufferLevel: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
shaka.net.NetworkingEngine.registerScheme(
|
||||
"inline",
|
||||
handleInlineDashManifest,
|
||||
)
|
||||
//this.demuxer.setAutoPlay(false)
|
||||
|
||||
this.demuxer.addEventListener("error", (event) => {
|
||||
// Event listeners
|
||||
this.demuxer.on(dashjs.MediaPlayer.events.ERROR, (event) => {
|
||||
console.error("Demuxer error", event)
|
||||
})
|
||||
}
|
||||
@ -263,40 +275,21 @@ export default class AudioBase {
|
||||
// if remaining time is less than 3s, try to init next item
|
||||
if (parseInt(remainingTime) <= 10) {
|
||||
// check if queue has next item
|
||||
if (this.player.queue.nextItems[0]) {
|
||||
if (
|
||||
this.player.queue.nextItems[0] &&
|
||||
!this.player.queue.nextItems[0]._initialized
|
||||
) {
|
||||
this.player.queue.nextItems[0] = await this.itemInit(
|
||||
this.player.queue.nextItems[0],
|
||||
)
|
||||
|
||||
if (
|
||||
this.demuxer &&
|
||||
this.player.queue.nextItems[0].source &&
|
||||
this.player.queue.nextItems[0].mpd_mode &&
|
||||
!this.player.queue.nextItems[0]._preloaded
|
||||
) {
|
||||
const manifest = this.player.queue.nextItems[0]
|
||||
|
||||
// preload next item
|
||||
this.console.time("preload next item")
|
||||
this.player.queue.nextItems[0]._preloaded =
|
||||
await this.demuxer.preload(
|
||||
"inline://" +
|
||||
manifest.source +
|
||||
"::" +
|
||||
manifest.dash_manifest,
|
||||
0,
|
||||
"application/dash+xml",
|
||||
)
|
||||
this.console.timeEnd("preload next item")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
async flush() {
|
||||
this.audio.pause()
|
||||
this.audio.currentTime = 0
|
||||
this.createDemuxer()
|
||||
await this.createDemuxer()
|
||||
}
|
||||
|
||||
audioEvents = {
|
||||
|
@ -72,6 +72,8 @@ export default class SyncRoom {
|
||||
track_manifest = {
|
||||
...currentItem.toSeriableObject(),
|
||||
}
|
||||
|
||||
delete track_manifest.source
|
||||
}
|
||||
|
||||
// check if has changed the track
|
||||
@ -98,8 +100,6 @@ export default class SyncRoom {
|
||||
}
|
||||
|
||||
syncState = async (data) => {
|
||||
console.log(data)
|
||||
|
||||
if (!data || !data.track_manifest) {
|
||||
return false
|
||||
}
|
||||
@ -129,11 +129,12 @@ export default class SyncRoom {
|
||||
const currentTime = this.player.seek()
|
||||
const offset = serverTime - currentTime
|
||||
|
||||
console.log({
|
||||
this.player.console.debug("sync_state", {
|
||||
serverPayload: data,
|
||||
serverTime: serverTime,
|
||||
currentTime: currentTime,
|
||||
maxTimeOffset: SyncRoom.maxTimeOffset,
|
||||
offset: offset,
|
||||
maxTimeOffset: SyncRoom.maxTimeOffset,
|
||||
})
|
||||
|
||||
if (
|
||||
@ -182,13 +183,13 @@ export default class SyncRoom {
|
||||
this.state.joined_room = null
|
||||
|
||||
if (this.socket) {
|
||||
await this.socket.disconnect()
|
||||
await this.socket.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
createSocket = async () => {
|
||||
if (this.socket) {
|
||||
await this.socket.disconnect()
|
||||
await this.socket.destroy()
|
||||
}
|
||||
|
||||
this.socket = new RTEngineClient({
|
||||
|
@ -45,12 +45,8 @@ export default class TrackManifest {
|
||||
this.source = params.source
|
||||
}
|
||||
|
||||
if (typeof params.dash_manifest !== "undefined") {
|
||||
this.dash_manifest = params.dash_manifest
|
||||
}
|
||||
|
||||
if (typeof params.encoded_manifest !== "undefined") {
|
||||
this.encoded_manifest = params.encoded_manifest
|
||||
if (typeof params.mpd_string !== "undefined") {
|
||||
this.mpd_string = params.mpd_string
|
||||
}
|
||||
|
||||
if (typeof params.metadata !== "undefined") {
|
||||
@ -128,7 +124,7 @@ export default class TrackManifest {
|
||||
}
|
||||
|
||||
serviceOperations = {
|
||||
fetchLyrics: async () => {
|
||||
fetchLyrics: async (options) => {
|
||||
if (!this._id) {
|
||||
return null
|
||||
}
|
||||
@ -137,6 +133,7 @@ export default class TrackManifest {
|
||||
"resolveLyrics",
|
||||
this.service,
|
||||
this,
|
||||
options,
|
||||
)
|
||||
|
||||
if (this.overrides) {
|
||||
@ -195,8 +192,7 @@ export default class TrackManifest {
|
||||
album: this.album,
|
||||
artist: this.artist,
|
||||
source: this.source,
|
||||
dash_manifest: this.dash_manifest,
|
||||
encoded_manifest: this.encoded_manifest,
|
||||
mpd_string: this.mpd_string,
|
||||
metadata: this.metadata,
|
||||
liked: this.liked,
|
||||
service: this.service,
|
||||
|
198
packages/app/src/cores/player/mpd_parser/constants.js
Normal file
198
packages/app/src/cores/player/mpd_parser/constants.js
Normal file
@ -0,0 +1,198 @@
|
||||
/**
|
||||
* The copyright in this software is being made available under the BSD License,
|
||||
* included below. This software may be subject to other third party and contributor
|
||||
* rights, including patent rights, and no such rights are granted under this license.
|
||||
*
|
||||
* Copyright (c) 2013, Dash Industry Forum.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
* * Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
* * Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation and/or
|
||||
* other materials provided with the distribution.
|
||||
* * Neither the name of Dash Industry Forum nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
|
||||
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, LOSS OF USE, DATA, OR
|
||||
* PROFITS, OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dash constants declaration
|
||||
* @ignore
|
||||
*/
|
||||
export default {
|
||||
ACCESSIBILITY: "Accessibility",
|
||||
ADAPTATION_SET: "AdaptationSet",
|
||||
ADAPTATION_SETS: "adaptationSets",
|
||||
ADAPTATION_SET_SWITCHING_SCHEME_ID_URI:
|
||||
"urn:mpeg:dash:adaptation-set-switching:2016",
|
||||
ADD: "add",
|
||||
ASSET_IDENTIFIER: "AssetIdentifier",
|
||||
AUDIO_CHANNEL_CONFIGURATION: "AudioChannelConfiguration",
|
||||
AUDIO_SAMPLING_RATE: "audioSamplingRate",
|
||||
AVAILABILITY_END_TIME: "availabilityEndTime",
|
||||
AVAILABILITY_START_TIME: "availabilityStartTime",
|
||||
AVAILABILITY_TIME_COMPLETE: "availabilityTimeComplete",
|
||||
AVAILABILITY_TIME_OFFSET: "availabilityTimeOffset",
|
||||
BANDWITH: "bandwidth",
|
||||
BASE_URL: "BaseURL",
|
||||
BITSTREAM_SWITCHING: "BitstreamSwitching",
|
||||
BITSTREAM_SWITCHING_MINUS: "bitstreamSwitching",
|
||||
BYTE_RANGE: "byteRange",
|
||||
CAPTION: "caption",
|
||||
CENC_DEFAULT_KID: "cenc:default_KID",
|
||||
CLIENT_DATA_REPORTING: "ClientDataReporting",
|
||||
CLIENT_REQUIREMENT: "clientRequirement",
|
||||
CMCD_PARAMETERS: "CMCDParameters",
|
||||
CODECS: "codecs",
|
||||
CODEC_PRIVATE_DATA: "codecPrivateData",
|
||||
CODING_DEPENDENCY: "codingDependency",
|
||||
CONTENT_COMPONENT: "ContentComponent",
|
||||
CONTENT_PROTECTION: "ContentProtection",
|
||||
CONTENT_STEERING: "ContentSteering",
|
||||
CONTENT_STEERING_RESPONSE: {
|
||||
VERSION: "VERSION",
|
||||
TTL: "TTL",
|
||||
RELOAD_URI: "RELOAD-URI",
|
||||
PATHWAY_PRIORITY: "PATHWAY-PRIORITY",
|
||||
PATHWAY_CLONES: "PATHWAY-CLONES",
|
||||
BASE_ID: "BASE-ID",
|
||||
ID: "ID",
|
||||
URI_REPLACEMENT: "URI-REPLACEMENT",
|
||||
HOST: "HOST",
|
||||
PARAMS: "PARAMS",
|
||||
},
|
||||
CONTENT_TYPE: "contentType",
|
||||
DEFAULT_SERVICE_LOCATION: "defaultServiceLocation",
|
||||
DEPENDENCY_ID: "dependencyId",
|
||||
DURATION: "duration",
|
||||
DVB_PRIORITY: "dvb:priority",
|
||||
DVB_WEIGHT: "dvb:weight",
|
||||
DVB_URL: "dvb:url",
|
||||
DVB_MIMETYPE: "dvb:mimeType",
|
||||
DVB_FONTFAMILY: "dvb:fontFamily",
|
||||
DYNAMIC: "dynamic",
|
||||
END_NUMBER: "endNumber",
|
||||
ESSENTIAL_PROPERTY: "EssentialProperty",
|
||||
EVENT: "Event",
|
||||
EVENT_STREAM: "EventStream",
|
||||
FORCED_SUBTITLE: "forced-subtitle",
|
||||
FRAMERATE: "frameRate",
|
||||
FRAME_PACKING: "FramePacking",
|
||||
GROUP_LABEL: "GroupLabel",
|
||||
HEIGHT: "height",
|
||||
ID: "id",
|
||||
INBAND: "inband",
|
||||
INBAND_EVENT_STREAM: "InbandEventStream",
|
||||
INDEX: "index",
|
||||
INDEX_RANGE: "indexRange",
|
||||
INITIALIZATION: "Initialization",
|
||||
INITIALIZATION_MINUS: "initialization",
|
||||
LA_URL: "Laurl",
|
||||
LA_URL_LOWER_CASE: "laurl",
|
||||
LABEL: "Label",
|
||||
LANG: "lang",
|
||||
LOCATION: "Location",
|
||||
MAIN: "main",
|
||||
MAXIMUM_SAP_PERIOD: "maximumSAPPeriod",
|
||||
MAX_PLAYOUT_RATE: "maxPlayoutRate",
|
||||
MAX_SEGMENT_DURATION: "maxSegmentDuration",
|
||||
MAX_SUBSEGMENT_DURATION: "maxSubsegmentDuration",
|
||||
MEDIA: "media",
|
||||
MEDIA_PRESENTATION_DURATION: "mediaPresentationDuration",
|
||||
MEDIA_RANGE: "mediaRange",
|
||||
MEDIA_STREAM_STRUCTURE_ID: "mediaStreamStructureId",
|
||||
METRICS: "Metrics",
|
||||
METRICS_MINUS: "metrics",
|
||||
MIME_TYPE: "mimeType",
|
||||
MINIMUM_UPDATE_PERIOD: "minimumUpdatePeriod",
|
||||
MIN_BUFFER_TIME: "minBufferTime",
|
||||
MP4_PROTECTION_SCHEME: "urn:mpeg:dash:mp4protection:2011",
|
||||
MPD: "MPD",
|
||||
MPD_TYPE: "mpd",
|
||||
MPD_PATCH_TYPE: "mpdpatch",
|
||||
ORIGINAL_MPD_ID: "mpdId",
|
||||
ORIGINAL_PUBLISH_TIME: "originalPublishTime",
|
||||
PATCH_LOCATION: "PatchLocation",
|
||||
PERIOD: "Period",
|
||||
PRESENTATION_TIME: "presentationTime",
|
||||
PRESENTATION_TIME_OFFSET: "presentationTimeOffset",
|
||||
PRO: "pro",
|
||||
PRODUCER_REFERENCE_TIME: "ProducerReferenceTime",
|
||||
PRODUCER_REFERENCE_TIME_TYPE: {
|
||||
ENCODER: "encoder",
|
||||
CAPTURED: "captured",
|
||||
APPLICATION: "application",
|
||||
},
|
||||
PROFILES: "profiles",
|
||||
PSSH: "pssh",
|
||||
PUBLISH_TIME: "publishTime",
|
||||
QUALITY_RANKING: "qualityRanking",
|
||||
QUERY_BEFORE_START: "queryBeforeStart",
|
||||
QUERY_PART: "$querypart$",
|
||||
RANGE: "range",
|
||||
RATING: "Rating",
|
||||
REF: "ref",
|
||||
REF_ID: "refId",
|
||||
REMOVE: "remove",
|
||||
REPLACE: "replace",
|
||||
REPORTING: "Reporting",
|
||||
REPRESENTATION: "Representation",
|
||||
REPRESENTATION_INDEX: "RepresentationIndex",
|
||||
ROBUSTNESS: "robustness",
|
||||
ROLE: "Role",
|
||||
S: "S",
|
||||
SAR: "sar",
|
||||
SCAN_TYPE: "scanType",
|
||||
SEGMENT_ALIGNMENT: "segmentAlignment",
|
||||
SEGMENT_BASE: "SegmentBase",
|
||||
SEGMENT_LIST: "SegmentList",
|
||||
SEGMENT_PROFILES: "segmentProfiles",
|
||||
SEGMENT_TEMPLATE: "SegmentTemplate",
|
||||
SEGMENT_TIMELINE: "SegmentTimeline",
|
||||
SEGMENT_TYPE: "segment",
|
||||
SEGMENT_URL: "SegmentURL",
|
||||
SERVICE_DESCRIPTION: "ServiceDescription",
|
||||
SERVICE_DESCRIPTION_LATENCY: "Latency",
|
||||
SERVICE_DESCRIPTION_OPERATING_BANDWIDTH: "OperatingBandwidth",
|
||||
SERVICE_DESCRIPTION_OPERATING_QUALITY: "OperatingQuality",
|
||||
SERVICE_DESCRIPTION_PLAYBACK_RATE: "PlaybackRate",
|
||||
SERVICE_DESCRIPTION_SCOPE: "Scope",
|
||||
SERVICE_LOCATION: "serviceLocation",
|
||||
SERVICE_LOCATIONS: "serviceLocations",
|
||||
SOURCE_URL: "sourceURL",
|
||||
START: "start",
|
||||
START_NUMBER: "startNumber",
|
||||
START_WITH_SAP: "startWithSAP",
|
||||
STATIC: "static",
|
||||
STEERING_TYPE: "steering",
|
||||
SUBSET: "Subset",
|
||||
SUBTITLE: "subtitle",
|
||||
SUB_REPRESENTATION: "SubRepresentation",
|
||||
SUB_SEGMENT_ALIGNMENT: "subsegmentAlignment",
|
||||
SUGGESTED_PRESENTATION_DELAY: "suggestedPresentationDelay",
|
||||
SUPPLEMENTAL_PROPERTY: "SupplementalProperty",
|
||||
SUPPLEMENTAL_CODECS: "scte214:supplementalCodecs",
|
||||
TIMESCALE: "timescale",
|
||||
TIMESHIFT_BUFFER_DEPTH: "timeShiftBufferDepth",
|
||||
TTL: "ttl",
|
||||
TYPE: "type",
|
||||
UTC_TIMING: "UTCTiming",
|
||||
VALUE: "value",
|
||||
VIEWPOINT: "Viewpoint",
|
||||
WALL_CLOCK_TIME: "wallClockTime",
|
||||
WIDTH: "width",
|
||||
}
|
131
packages/app/src/cores/player/mpd_parser/index.js
Normal file
131
packages/app/src/cores/player/mpd_parser/index.js
Normal file
@ -0,0 +1,131 @@
|
||||
import { parseXml as cmlParseXml } from "@svta/common-media-library/xml/parseXml.js"
|
||||
import DashConstants from "./constants"
|
||||
|
||||
import DurationMatcher from "./matchers/duration"
|
||||
import DateTimeMatcher from "./matchers/datetime"
|
||||
import NumericMatcher from "./matchers/numeric"
|
||||
import LangMatcher from "./matchers/lang"
|
||||
|
||||
const arrayNodes = [
|
||||
DashConstants.PERIOD,
|
||||
DashConstants.BASE_URL,
|
||||
DashConstants.ADAPTATION_SET,
|
||||
DashConstants.REPRESENTATION,
|
||||
DashConstants.CONTENT_PROTECTION,
|
||||
DashConstants.ROLE,
|
||||
DashConstants.ACCESSIBILITY,
|
||||
DashConstants.AUDIO_CHANNEL_CONFIGURATION,
|
||||
DashConstants.CONTENT_COMPONENT,
|
||||
DashConstants.ESSENTIAL_PROPERTY,
|
||||
DashConstants.LABEL,
|
||||
DashConstants.S,
|
||||
DashConstants.SEGMENT_URL,
|
||||
DashConstants.EVENT,
|
||||
DashConstants.EVENT_STREAM,
|
||||
DashConstants.LOCATION,
|
||||
DashConstants.SERVICE_DESCRIPTION,
|
||||
DashConstants.SUPPLEMENTAL_PROPERTY,
|
||||
DashConstants.METRICS,
|
||||
DashConstants.REPORTING,
|
||||
DashConstants.PATCH_LOCATION,
|
||||
DashConstants.REPLACE,
|
||||
DashConstants.ADD,
|
||||
DashConstants.REMOVE,
|
||||
DashConstants.UTC_TIMING,
|
||||
DashConstants.INBAND_EVENT_STREAM,
|
||||
DashConstants.PRODUCER_REFERENCE_TIME,
|
||||
DashConstants.CONTENT_STEERING,
|
||||
]
|
||||
|
||||
function processNode(node, matchers) {
|
||||
// Convert tag name
|
||||
let p = node.nodeName.indexOf(":")
|
||||
if (p !== -1) {
|
||||
node.__prefix = node.prefix
|
||||
node.nodeName = node.localName
|
||||
}
|
||||
|
||||
const { childNodes, attributes, nodeName } = node
|
||||
node.tagName = nodeName
|
||||
|
||||
// Convert attributes
|
||||
for (let k in attributes) {
|
||||
let value = attributes[k]
|
||||
|
||||
if (nodeName === "S") {
|
||||
value = parseInt(value)
|
||||
} else {
|
||||
for (let i = 0, len = matchers.length; i < len; i++) {
|
||||
const matcher = matchers[i]
|
||||
if (matcher.test(nodeName, k, value)) {
|
||||
value = matcher.converter(value)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node[k] = value
|
||||
}
|
||||
|
||||
// Convert children
|
||||
const len = childNodes?.length
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const child = childNodes[i]
|
||||
|
||||
if (child.nodeName === "#text") {
|
||||
node.__text = child.nodeValue
|
||||
continue
|
||||
}
|
||||
|
||||
processNode(child, matchers)
|
||||
|
||||
const { nodeName } = child
|
||||
|
||||
if (Array.isArray(node[nodeName])) {
|
||||
node[nodeName].push(child)
|
||||
} else if (arrayNodes.indexOf(nodeName) !== -1) {
|
||||
if (!node[nodeName]) {
|
||||
node[nodeName] = []
|
||||
}
|
||||
node[nodeName].push(child)
|
||||
} else {
|
||||
node[nodeName] = child
|
||||
}
|
||||
}
|
||||
|
||||
node.__children = childNodes
|
||||
}
|
||||
|
||||
export default async (mpd_string, url) => {
|
||||
let manifest = {
|
||||
protocol: "DASH",
|
||||
}
|
||||
|
||||
const matchers = [
|
||||
new DurationMatcher(),
|
||||
new DateTimeMatcher(),
|
||||
new NumericMatcher(),
|
||||
new LangMatcher(),
|
||||
]
|
||||
|
||||
const xml = cmlParseXml(mpd_string)
|
||||
|
||||
const root =
|
||||
xml.childNodes.find(
|
||||
(child) => child.nodeName === "MPD" || child.nodeName === "Patch",
|
||||
) || xml.childNodes[0]
|
||||
|
||||
processNode(root, matchers)
|
||||
|
||||
manifest = {
|
||||
...manifest,
|
||||
...root,
|
||||
loadedTime: new Date(),
|
||||
url: url,
|
||||
originalUrl: url,
|
||||
baseUri: url,
|
||||
}
|
||||
|
||||
return manifest
|
||||
}
|
52
packages/app/src/cores/player/mpd_parser/matchers/base.js
Normal file
52
packages/app/src/cores/player/mpd_parser/matchers/base.js
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The copyright in this software is being made available under the BSD License,
|
||||
* included below. This software may be subject to other third party and contributor
|
||||
* rights, including patent rights, and no such rights are granted under this license.
|
||||
*
|
||||
* Copyright (c) 2013, Dash Industry Forum.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
* * Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
* * Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation and/or
|
||||
* other materials provided with the distribution.
|
||||
* * Neither the name of Dash Industry Forum nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
|
||||
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @classdesc a base type for matching and converting types in manifest to
|
||||
* something more useful
|
||||
* @ignore
|
||||
*/
|
||||
class BaseMatcher {
|
||||
constructor(test, converter) {
|
||||
this._test = test
|
||||
this._converter = converter
|
||||
}
|
||||
|
||||
get test() {
|
||||
return this._test
|
||||
}
|
||||
|
||||
get converter() {
|
||||
return this._converter
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseMatcher
|
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The copyright in this software is being made available under the BSD License,
|
||||
* included below. This software may be subject to other third party and contributor
|
||||
* rights, including patent rights, and no such rights are granted under this license.
|
||||
*
|
||||
* Copyright (c) 2013, Dash Industry Forum.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
* * Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
* * Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation and/or
|
||||
* other materials provided with the distribution.
|
||||
* * Neither the name of Dash Industry Forum nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
|
||||
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
/**
|
||||
* @classdesc matches and converts xs:datetime to Date
|
||||
*/
|
||||
import BaseMatcher from "./base"
|
||||
|
||||
const SECONDS_IN_MIN = 60
|
||||
const MINUTES_IN_HOUR = 60
|
||||
const MILLISECONDS_IN_SECONDS = 1000
|
||||
|
||||
const datetimeRegex =
|
||||
/^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})(?::([0-9]*)(\.[0-9]*)?)?(?:([+-])([0-9]{2})(?::?)([0-9]{2}))?/
|
||||
|
||||
class DateTimeMatcher extends BaseMatcher {
|
||||
constructor() {
|
||||
super(
|
||||
(tagName, attrName, value) => datetimeRegex.test(value),
|
||||
(str) => {
|
||||
const match = datetimeRegex.exec(str)
|
||||
let utcDate
|
||||
|
||||
// If the string does not contain a timezone offset different browsers can interpret it either
|
||||
// as UTC or as a local time so we have to parse the string manually to normalize the given date value for
|
||||
// all browsers
|
||||
utcDate = Date.UTC(
|
||||
parseInt(match[1], 10),
|
||||
parseInt(match[2], 10) - 1, // months start from zero
|
||||
parseInt(match[3], 10),
|
||||
parseInt(match[4], 10),
|
||||
parseInt(match[5], 10),
|
||||
(match[6] && parseInt(match[6], 10)) || 0,
|
||||
(match[7] &&
|
||||
parseFloat(match[7]) * MILLISECONDS_IN_SECONDS) ||
|
||||
0,
|
||||
)
|
||||
|
||||
// If the date has timezone offset take it into account as well
|
||||
if (match[9] && match[10]) {
|
||||
const timezoneOffset =
|
||||
parseInt(match[9], 10) * MINUTES_IN_HOUR +
|
||||
parseInt(match[10], 10)
|
||||
utcDate +=
|
||||
(match[8] === "+" ? -1 : +1) *
|
||||
timezoneOffset *
|
||||
SECONDS_IN_MIN *
|
||||
MILLISECONDS_IN_SECONDS
|
||||
}
|
||||
|
||||
return new Date(utcDate)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default DateTimeMatcher
|
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The copyright in this software is being made available under the BSD License,
|
||||
* included below. This software may be subject to other third party and contributor
|
||||
* rights, including patent rights, and no such rights are granted under this license.
|
||||
*
|
||||
* Copyright (c) 2013, Dash Industry Forum.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
* * Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
* * Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation and/or
|
||||
* other materials provided with the distribution.
|
||||
* * Neither the name of Dash Industry Forum nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
|
||||
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
/**
|
||||
* @classdesc matches and converts xs:duration to seconds
|
||||
*/
|
||||
import BaseMatcher from "./base"
|
||||
import DashConstants from "../constants"
|
||||
|
||||
const durationRegex =
|
||||
/^([-])?P(([\d.]*)Y)?(([\d.]*)M)?(([\d.]*)D)?T?(([\d.]*)H)?(([\d.]*)M)?(([\d.]*)S)?/
|
||||
|
||||
const SECONDS_IN_YEAR = 365 * 24 * 60 * 60
|
||||
const SECONDS_IN_MONTH = 30 * 24 * 60 * 60
|
||||
const SECONDS_IN_DAY = 24 * 60 * 60
|
||||
const SECONDS_IN_HOUR = 60 * 60
|
||||
const SECONDS_IN_MIN = 60
|
||||
|
||||
class DurationMatcher extends BaseMatcher {
|
||||
constructor() {
|
||||
super(
|
||||
(tagName, attrName, value) => {
|
||||
const attributeList = [
|
||||
DashConstants.MIN_BUFFER_TIME,
|
||||
DashConstants.MEDIA_PRESENTATION_DURATION,
|
||||
DashConstants.MINIMUM_UPDATE_PERIOD,
|
||||
DashConstants.TIMESHIFT_BUFFER_DEPTH,
|
||||
DashConstants.MAX_SEGMENT_DURATION,
|
||||
DashConstants.MAX_SUBSEGMENT_DURATION,
|
||||
DashConstants.SUGGESTED_PRESENTATION_DELAY,
|
||||
DashConstants.START,
|
||||
"starttime",
|
||||
DashConstants.DURATION,
|
||||
]
|
||||
const len = attributeList.length
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (attrName === attributeList[i]) {
|
||||
return durationRegex.test(value)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
(str) => {
|
||||
//str = "P10Y10M10DT10H10M10.1S";
|
||||
const match = durationRegex.exec(str)
|
||||
let result =
|
||||
parseFloat(match[3] || 0) * SECONDS_IN_YEAR +
|
||||
parseFloat(match[5] || 0) * SECONDS_IN_MONTH +
|
||||
parseFloat(match[7] || 0) * SECONDS_IN_DAY +
|
||||
parseFloat(match[9] || 0) * SECONDS_IN_HOUR +
|
||||
parseFloat(match[11] || 0) * SECONDS_IN_MIN +
|
||||
parseFloat(match[13] || 0)
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
result = -result
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default DurationMatcher
|
71
packages/app/src/cores/player/mpd_parser/matchers/lang.js
Normal file
71
packages/app/src/cores/player/mpd_parser/matchers/lang.js
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The copyright in this software is being made available under the BSD License,
|
||||
* included below. This software may be subject to other third party and contributor
|
||||
* rights, including patent rights, and no such rights are granted under this license.
|
||||
*
|
||||
* Copyright (c) 2013, Dash Industry Forum.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
* * Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
* * Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation and/or
|
||||
* other materials provided with the distribution.
|
||||
* * Neither the name of Dash Industry Forum nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
|
||||
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
/**
|
||||
* @classdesc Matches and converts any ISO 639 language tag to BCP-47 language tags
|
||||
*/
|
||||
import BaseMatcher from "./base"
|
||||
import DashConstants from "../constants"
|
||||
import { bcp47Normalize } from "bcp-47-normalize"
|
||||
|
||||
class LangMatcher extends BaseMatcher {
|
||||
constructor() {
|
||||
super(
|
||||
(tagName, attr /*, value*/) => {
|
||||
const stringAttrsInElements = {
|
||||
[DashConstants.ADAPTATION_SET]: [DashConstants.LANG],
|
||||
[DashConstants.REPRESENTATION]: [DashConstants.LANG],
|
||||
[DashConstants.CONTENT_COMPONENT]: [DashConstants.LANG],
|
||||
[DashConstants.LABEL]: [DashConstants.LANG],
|
||||
[DashConstants.GROUP_LABEL]: [DashConstants.LANG],
|
||||
// still missing from 23009-1: Preselection@lang, ProgramInformation@lang
|
||||
}
|
||||
if (stringAttrsInElements.hasOwnProperty(tagName)) {
|
||||
let attrNames = stringAttrsInElements[tagName]
|
||||
if (attrNames !== undefined) {
|
||||
return attrNames.indexOf(attr) >= 0
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
(str) => {
|
||||
let lang = bcp47Normalize(str)
|
||||
if (lang) {
|
||||
return lang
|
||||
}
|
||||
return String(str)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default LangMatcher
|
52
packages/app/src/cores/player/mpd_parser/matchers/numeric.js
Normal file
52
packages/app/src/cores/player/mpd_parser/matchers/numeric.js
Normal file
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* The copyright in this software is being made available under the BSD License,
|
||||
* included below. This software may be subject to other third party and contributor
|
||||
* rights, including patent rights, and no such rights are granted under this license.
|
||||
*
|
||||
* Copyright (c) 2013, Dash Industry Forum.
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without modification,
|
||||
* are permitted provided that the following conditions are met:
|
||||
* * Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
* * Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation and/or
|
||||
* other materials provided with the distribution.
|
||||
* * Neither the name of Dash Industry Forum nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from this software
|
||||
* without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS AS IS AND ANY
|
||||
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||
* NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
* PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
* WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
* POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
/**
|
||||
* @classdesc Matches and converts xs:numeric to float
|
||||
*/
|
||||
import BaseMatcher from "./base"
|
||||
import DashConstants from "../constants"
|
||||
|
||||
const numericRegex = /^[-+]?[0-9]+[.]?[0-9]*([eE][-+]?[0-9]+)?$/
|
||||
|
||||
const StringAttributeList = [DashConstants.ID]
|
||||
|
||||
class NumericMatcher extends BaseMatcher {
|
||||
constructor() {
|
||||
super(
|
||||
(tagName, attrName, value) =>
|
||||
numericRegex.test(value) &&
|
||||
StringAttributeList.indexOf(attrName) === -1,
|
||||
(str) => parseFloat(str),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default NumericMatcher
|
92
packages/app/src/hooks/onPageMount.js
Normal file
92
packages/app/src/hooks/onPageMount.js
Normal file
@ -0,0 +1,92 @@
|
||||
import config from "@config"
|
||||
|
||||
const isAuthenticated = () => {
|
||||
return !!app.userData
|
||||
}
|
||||
|
||||
const handleAuthentication = (declaration) => {
|
||||
if (
|
||||
!isAuthenticated() &&
|
||||
!declaration.public &&
|
||||
window.location.pathname !== config.app?.authPath
|
||||
) {
|
||||
const authPath = config.app?.authPath ?? "/login"
|
||||
|
||||
if (typeof window.app?.location?.push === "function") {
|
||||
window.app.location.push(authPath)
|
||||
|
||||
if (app.cores?.notifications?.new) {
|
||||
app.cores.notifications.new({
|
||||
title: "Please login to use this feature.",
|
||||
duration: 15,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
window.location.href = authPath
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleLayout = (declaration) => {
|
||||
if (declaration.useLayout && app.layout?.set) {
|
||||
app.layout.set(declaration.useLayout)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCenteredContent = (declaration) => {
|
||||
if (
|
||||
typeof declaration.centeredContent !== "undefined" &&
|
||||
app.layout?.toggleCenteredContent
|
||||
) {
|
||||
let finalBool = null
|
||||
|
||||
if (typeof declaration.centeredContent === "boolean") {
|
||||
finalBool = declaration.centeredContent
|
||||
} else {
|
||||
finalBool = app.isMobile
|
||||
? (declaration.centeredContent?.mobile ?? null)
|
||||
: (declaration.centeredContent?.desktop ?? null)
|
||||
}
|
||||
|
||||
app.layout.toggleCenteredContent(finalBool)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTitle = (declaration) => {
|
||||
if (typeof declaration.useTitle !== "undefined") {
|
||||
let title = declaration.useTitle
|
||||
|
||||
document.title = `${title} - ${config.app.siteName}`
|
||||
} else {
|
||||
document.title = config.app.siteName
|
||||
}
|
||||
}
|
||||
|
||||
export default ({ element, declaration }) => {
|
||||
const options = element.options ?? {}
|
||||
|
||||
// Handle authentication first
|
||||
const isAuthorized = handleAuthentication(declaration)
|
||||
|
||||
if (isAuthorized) {
|
||||
handleLayout(declaration)
|
||||
handleCenteredContent(declaration)
|
||||
handleTitle(declaration)
|
||||
}
|
||||
|
||||
if (options.layout) {
|
||||
if (typeof options.layout.type === "string" && app.layout?.set) {
|
||||
app.layout.set(options.layout.type)
|
||||
}
|
||||
|
||||
if (
|
||||
typeof options.layout.centeredContent === "boolean" &&
|
||||
app.layout?.toggleCenteredContent
|
||||
) {
|
||||
app.layout.toggleCenteredContent()
|
||||
}
|
||||
}
|
||||
}
|
19
packages/app/src/hooks/useCenteredContainer.js
Executable file
19
packages/app/src/hooks/useCenteredContainer.js
Executable file
@ -0,0 +1,19 @@
|
||||
import React from "react"
|
||||
|
||||
export default (to) => {
|
||||
React.useEffect(() => {
|
||||
if (typeof to !== "undefined") {
|
||||
app.layout.toggleCenteredContent(to)
|
||||
|
||||
return () => {
|
||||
app.layout.toggleCenteredContent(!!to)
|
||||
}
|
||||
}
|
||||
|
||||
app.layout.toggleCenteredContent(true)
|
||||
|
||||
return () => {
|
||||
app.layout.toggleCenteredContent(false)
|
||||
}
|
||||
}, [])
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
export default (to) => {
|
||||
React.useEffect(() => {
|
||||
if (typeof to !== "undefined") {
|
||||
app.layout.toggleCenteredContent(to)
|
||||
|
||||
return () => {
|
||||
app.layout.toggleCenteredContent(!!to)
|
||||
}
|
||||
}
|
||||
|
||||
app.layout.toggleCenteredContent(true)
|
||||
|
||||
return () => {
|
||||
app.layout.toggleCenteredContent(false)
|
||||
}
|
||||
}, [])
|
||||
}
|
@ -32,12 +32,14 @@ export default (trackManifest) => {
|
||||
}
|
||||
}, [trackManifest])
|
||||
|
||||
const dominantColor = {
|
||||
"--dominant-color": getDominantColorStr(coverAnalysis),
|
||||
}
|
||||
|
||||
return {
|
||||
coverAnalysis,
|
||||
dominantColor,
|
||||
dominantColor: getDominantColorStr(coverAnalysis),
|
||||
cssVars: {
|
||||
"--dominant-color": getDominantColorStr(coverAnalysis),
|
||||
"--dominant-color-r": coverAnalysis?.value?.[0],
|
||||
"--dominant-color-g": coverAnalysis?.value?.[1],
|
||||
"--dominant-color-b": coverAnalysis?.value?.[2],
|
||||
},
|
||||
}
|
||||
}
|
81
packages/app/src/hooks/useLyrics.js
Normal file
81
packages/app/src/hooks/useLyrics.js
Normal file
@ -0,0 +1,81 @@
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import parseTimeToMs from "@utils/parseTimeToMs"
|
||||
|
||||
export default ({ trackManifest }) => {
|
||||
const [lyrics, setLyrics] = useState(null)
|
||||
|
||||
const processLyrics = useCallback((rawLyrics) => {
|
||||
if (!rawLyrics) return false
|
||||
|
||||
return rawLyrics.sync_audio_at && !rawLyrics.sync_audio_at_ms
|
||||
? {
|
||||
...rawLyrics,
|
||||
sync_audio_at_ms: parseTimeToMs(rawLyrics.sync_audio_at),
|
||||
}
|
||||
: rawLyrics
|
||||
}, [])
|
||||
|
||||
const loadCurrentTrackLyrics = useCallback(
|
||||
async (options) => {
|
||||
let data = null
|
||||
|
||||
const track = app.cores.player.track()
|
||||
|
||||
if (!trackManifest || !track) {
|
||||
return null
|
||||
}
|
||||
|
||||
// if is in sync mode, fetch lyrics from sync room
|
||||
if (app.cores.player.inOnSyncMode()) {
|
||||
const syncRoomSocket = app.cores.player.sync().socket
|
||||
|
||||
if (syncRoomSocket) {
|
||||
data = await syncRoomSocket
|
||||
.call("sync_room:request_lyrics")
|
||||
.catch(() => null)
|
||||
}
|
||||
} else {
|
||||
data = await track.serviceOperations
|
||||
.fetchLyrics(options)
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
// if no data founded, flush lyrics
|
||||
if (!data) {
|
||||
return setLyrics(null)
|
||||
}
|
||||
|
||||
// process & set lyrics
|
||||
data = processLyrics(data)
|
||||
setLyrics(data)
|
||||
|
||||
console.log("Track Lyrics:", data)
|
||||
},
|
||||
[trackManifest, processLyrics],
|
||||
)
|
||||
|
||||
// Load lyrics when track manifest changes or when translation is toggled
|
||||
useEffect(() => {
|
||||
if (!trackManifest) {
|
||||
setLyrics(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!lyrics || lyrics.track_id !== trackManifest._id) {
|
||||
loadCurrentTrackLyrics({
|
||||
language: app.cores.settings.get("lyrics:prefer_translation")
|
||||
? app.cores.settings.get("app:language").split("_")[0]
|
||||
: null,
|
||||
})
|
||||
}
|
||||
}, [trackManifest, lyrics?.track_id, loadCurrentTrackLyrics])
|
||||
|
||||
return {
|
||||
lyrics,
|
||||
setLyrics,
|
||||
loadCurrentTrackLyrics,
|
||||
}
|
||||
}
|
@ -1,69 +0,0 @@
|
||||
import { useState, useCallback, useEffect } from "react"
|
||||
import parseTimeToMs from "@utils/parseTimeToMs"
|
||||
|
||||
export default ({ trackManifest }) => {
|
||||
const [lyrics, setLyrics] = useState(null)
|
||||
|
||||
const processLyrics = useCallback((rawLyrics) => {
|
||||
if (!rawLyrics) return false
|
||||
|
||||
return rawLyrics.sync_audio_at && !rawLyrics.sync_audio_at_ms
|
||||
? {
|
||||
...rawLyrics,
|
||||
sync_audio_at_ms: parseTimeToMs(rawLyrics.sync_audio_at),
|
||||
}
|
||||
: rawLyrics
|
||||
}, [])
|
||||
|
||||
const loadCurrentTrackLyrics = useCallback(async () => {
|
||||
let data = null
|
||||
|
||||
const track = app.cores.player.track()
|
||||
|
||||
if (!trackManifest || !track) {
|
||||
return null
|
||||
}
|
||||
|
||||
// if is in sync mode, fetch lyrics from sync room
|
||||
if (app.cores.player.inOnSyncMode()) {
|
||||
const syncRoomSocket = app.cores.player.sync().socket
|
||||
|
||||
if (syncRoomSocket) {
|
||||
data = await syncRoomSocket
|
||||
.call("sync_room:request_lyrics")
|
||||
.catch(() => null)
|
||||
}
|
||||
} else {
|
||||
data = await track.serviceOperations.fetchLyrics().catch(() => null)
|
||||
}
|
||||
|
||||
// if no data founded, flush lyrics
|
||||
if (!data) {
|
||||
return setLyrics(null)
|
||||
}
|
||||
|
||||
// process & set lyrics
|
||||
data = processLyrics(data)
|
||||
setLyrics(data)
|
||||
|
||||
console.log("Track Lyrics:", data)
|
||||
}, [trackManifest, processLyrics])
|
||||
|
||||
// Load lyrics when track manifest changes or when translation is toggled
|
||||
useEffect(() => {
|
||||
if (!trackManifest) {
|
||||
setLyrics(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!lyrics || lyrics.track_id !== trackManifest._id) {
|
||||
loadCurrentTrackLyrics()
|
||||
}
|
||||
}, [trackManifest, lyrics?.track_id, loadCurrentTrackLyrics])
|
||||
|
||||
return {
|
||||
lyrics,
|
||||
setLyrics,
|
||||
loadCurrentTrackLyrics,
|
||||
}
|
||||
}
|
19
packages/app/src/hooks/useMaxScreen.js
Normal file
19
packages/app/src/hooks/useMaxScreen.js
Normal file
@ -0,0 +1,19 @@
|
||||
import React from "react"
|
||||
|
||||
export default () => {
|
||||
const enterPlayerAnimation = () => {
|
||||
app.controls.toggleUIVisibility(false)
|
||||
}
|
||||
|
||||
const exitPlayerAnimation = () => {
|
||||
app.controls.toggleUIVisibility(true)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
enterPlayerAnimation()
|
||||
|
||||
return () => {
|
||||
exitPlayerAnimation()
|
||||
}
|
||||
}, [])
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
export default () => {
|
||||
const enterPlayerAnimation = () => {
|
||||
app.cores.style.applyTemporalVariant("dark")
|
||||
app.layout.toggleCompactMode(true)
|
||||
app.layout.toggleCenteredContent(false)
|
||||
app.controls.toggleUIVisibility(false)
|
||||
}
|
||||
|
||||
const exitPlayerAnimation = () => {
|
||||
app.cores.style.applyVariant(app.cores.style.getStoragedVariantKey())
|
||||
app.layout.toggleCompactMode(false)
|
||||
app.layout.toggleCenteredContent(true)
|
||||
app.controls.toggleUIVisibility(true)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
enterPlayerAnimation()
|
||||
|
||||
return () => {
|
||||
exitPlayerAnimation()
|
||||
}
|
||||
}, [])
|
||||
}
|
39
packages/app/src/hooks/useUrlQueryActiveKey.js
Executable file
39
packages/app/src/hooks/useUrlQueryActiveKey.js
Executable file
@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
|
||||
export default ({ defaultKey = "0", queryKey = "key" }) => {
|
||||
const [activeKey, setActiveKey] = React.useState(
|
||||
new URLSearchParams(window.location.search).get(queryKey) ?? defaultKey,
|
||||
)
|
||||
|
||||
const replaceQueryTypeToCurrentTab = (key) => {
|
||||
if (!key) {
|
||||
// delete query
|
||||
return history.pushState(undefined, "", window.location.pathname)
|
||||
}
|
||||
|
||||
return history.pushState(undefined, "", `?${queryKey}=${key}`)
|
||||
}
|
||||
|
||||
const changeActiveKey = (key) => {
|
||||
setActiveKey(key)
|
||||
replaceQueryTypeToCurrentTab(key)
|
||||
}
|
||||
|
||||
const onHistoryChange = () => {
|
||||
const newActiveKey = new URLSearchParams(window.location.search).get(
|
||||
queryKey,
|
||||
)
|
||||
|
||||
setActiveKey(newActiveKey ?? defaultKey)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("popstate", onHistoryChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("popstate", onHistoryChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return [activeKey, changeActiveKey]
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import React from "react"
|
||||
|
||||
export default ({
|
||||
defaultKey = "0",
|
||||
queryKey = "key",
|
||||
}) => {
|
||||
const [activeKey, setActiveKey] = React.useState(new URLSearchParams(window.location.search).get(queryKey) ?? defaultKey)
|
||||
|
||||
const replaceQueryTypeToCurrentTab = (key) => {
|
||||
if (!key) {
|
||||
// delete query
|
||||
return history.pushState(undefined, "", window.location.pathname)
|
||||
}
|
||||
|
||||
return history.pushState(undefined, "", `?${queryKey}=${key}`)
|
||||
}
|
||||
|
||||
const changeActiveKey = (key) => {
|
||||
setActiveKey(key)
|
||||
replaceQueryTypeToCurrentTab(key)
|
||||
}
|
||||
|
||||
return [
|
||||
activeKey,
|
||||
changeActiveKey,
|
||||
]
|
||||
}
|
148
packages/app/src/layouts/components/drawer/component.jsx
Normal file
148
packages/app/src/layouts/components/drawer/component.jsx
Normal file
@ -0,0 +1,148 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react"
|
||||
import PropTypes from "prop-types"
|
||||
import classnames from "classnames"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import DrawerHeader from "./header"
|
||||
|
||||
const Drawer = React.memo(
|
||||
forwardRef(({ id, children, options = {}, controller }, ref) => {
|
||||
const [header, setHeader] = useState(options.header)
|
||||
|
||||
const {
|
||||
position = "left",
|
||||
style = {},
|
||||
props: componentProps = {},
|
||||
onDone,
|
||||
onFail,
|
||||
} = options
|
||||
|
||||
const setExtraActions = useCallback((actions) => {
|
||||
setHeader((prev) => ({ ...prev, actions: actions }))
|
||||
}, [])
|
||||
|
||||
const setDrawerHeader = useCallback((header) => {
|
||||
setHeader(header)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
if (typeof options.onClose === "function") {
|
||||
options.onClose()
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
controller.close(id, { transition: 150 })
|
||||
}, 150)
|
||||
}, [id, controller, options.onClose])
|
||||
|
||||
const handleDone = useCallback(
|
||||
(...context) => {
|
||||
if (typeof onDone === "function") {
|
||||
onDone(context)
|
||||
}
|
||||
},
|
||||
[onDone],
|
||||
)
|
||||
|
||||
const handleFail = useCallback(
|
||||
(...context) => {
|
||||
if (typeof onFail === "function") {
|
||||
onFail(context)
|
||||
}
|
||||
},
|
||||
[onFail],
|
||||
)
|
||||
|
||||
const animationVariants = useMemo(() => {
|
||||
const slideDirection = position === "right" ? 100 : -100
|
||||
|
||||
return {
|
||||
initial: {
|
||||
x: slideDirection,
|
||||
opacity: 0,
|
||||
},
|
||||
animate: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: {
|
||||
x: slideDirection,
|
||||
opacity: 0,
|
||||
},
|
||||
}
|
||||
}, [position])
|
||||
|
||||
const enhancedComponentProps = useMemo(
|
||||
() => ({
|
||||
...componentProps,
|
||||
setHeader,
|
||||
close: handleClose,
|
||||
handleDone,
|
||||
handleFail,
|
||||
}),
|
||||
[componentProps, handleClose, handleDone, handleFail],
|
||||
)
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
close: handleClose,
|
||||
handleDone,
|
||||
handleFail,
|
||||
options,
|
||||
id,
|
||||
}),
|
||||
[handleClose, handleDone, handleFail, options, id],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller) {
|
||||
throw new Error(`Cannot mount a drawer without a controller`)
|
||||
}
|
||||
|
||||
if (!children) {
|
||||
throw new Error(`Empty component`)
|
||||
}
|
||||
}, [controller, children])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
key={id}
|
||||
id={id}
|
||||
className={classnames("drawer", `drawer-${position}`)}
|
||||
style={style}
|
||||
{...animationVariants}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 20,
|
||||
}}
|
||||
>
|
||||
{header && <DrawerHeader {...header} onClose={handleClose} />}
|
||||
|
||||
<div className="drawer-content">
|
||||
{React.createElement(children, enhancedComponentProps)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
Drawer.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
children: PropTypes.elementType.isRequired,
|
||||
options: PropTypes.object,
|
||||
controller: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
export default Drawer
|
37
packages/app/src/layouts/components/drawer/header.jsx
Normal file
37
packages/app/src/layouts/components/drawer/header.jsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from "react"
|
||||
import PropTypes from "prop-types"
|
||||
|
||||
const DrawerHeader = ({ title, actions, onClose, showCloseButton = true }) => {
|
||||
if (!title && !actions && !showCloseButton) return null
|
||||
|
||||
return (
|
||||
<div className="drawer-header">
|
||||
<div className="drawer-header-content">
|
||||
{title && <h3 className="drawer-title">{title}</h3>}
|
||||
<div className="drawer-header-actions">
|
||||
{actions && (
|
||||
<div className="drawer-custom-actions">{actions}</div>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
className="drawer-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
DrawerHeader.propTypes = {
|
||||
title: PropTypes.string,
|
||||
actions: PropTypes.node,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
showCloseButton: PropTypes.bool,
|
||||
}
|
||||
|
||||
export default DrawerHeader
|
516
packages/app/src/layouts/components/drawer/hooks.js
Normal file
516
packages/app/src/layouts/components/drawer/hooks.js
Normal file
@ -0,0 +1,516 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
||||
import { useDrawer } from "./index.jsx"
|
||||
|
||||
/**
|
||||
* Hook for managing drawer state with local persistence
|
||||
*/
|
||||
export const useDrawerState = (drawerId, initialOptions = {}) => {
|
||||
const drawer = useDrawer()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [options, setOptions] = useState(initialOptions)
|
||||
const optionsRef = useRef(options)
|
||||
|
||||
useEffect(() => {
|
||||
optionsRef.current = options
|
||||
}, [options])
|
||||
|
||||
const open = useCallback(
|
||||
(component, newOptions = {}) => {
|
||||
const mergedOptions = { ...optionsRef.current, ...newOptions }
|
||||
setOptions(mergedOptions)
|
||||
setIsOpen(true)
|
||||
drawer.open(drawerId, component, mergedOptions)
|
||||
},
|
||||
[drawer, drawerId],
|
||||
)
|
||||
|
||||
const close = useCallback(
|
||||
(params = {}) => {
|
||||
setIsOpen(false)
|
||||
drawer.close(drawerId, params)
|
||||
},
|
||||
[drawer, drawerId],
|
||||
)
|
||||
|
||||
const toggle = useCallback(
|
||||
(component, newOptions = {}) => {
|
||||
if (isOpen) {
|
||||
close()
|
||||
} else {
|
||||
open(component, newOptions)
|
||||
}
|
||||
},
|
||||
[isOpen, open, close],
|
||||
)
|
||||
|
||||
const updateOptions = useCallback((newOptions) => {
|
||||
setOptions((prev) => ({ ...prev, ...newOptions }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
options,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
updateOptions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing drawer queues and sequences
|
||||
*/
|
||||
export const useDrawerQueue = () => {
|
||||
const drawer = useDrawer()
|
||||
const [queue, setQueue] = useState([])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const isProcessing = useRef(false)
|
||||
|
||||
const addToQueue = useCallback((id, component, options = {}) => {
|
||||
setQueue((prev) => [...prev, { id, component, options }])
|
||||
}, [])
|
||||
|
||||
const processNext = useCallback(async () => {
|
||||
if (isProcessing.current) return
|
||||
|
||||
isProcessing.current = true
|
||||
|
||||
const nextIndex = currentIndex + 1
|
||||
if (nextIndex < queue.length) {
|
||||
const item = queue[nextIndex]
|
||||
setCurrentIndex(nextIndex)
|
||||
|
||||
// Close previous drawer if exists
|
||||
if (currentIndex >= 0) {
|
||||
const prevItem = queue[currentIndex]
|
||||
await new Promise((resolve) => {
|
||||
drawer.close(prevItem.id, { transition: 200 })
|
||||
setTimeout(resolve, 200)
|
||||
})
|
||||
}
|
||||
|
||||
// Open next drawer
|
||||
drawer.open(item.id, item.component, {
|
||||
...item.options,
|
||||
onClose: () => {
|
||||
item.options.onClose?.()
|
||||
processNext()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
isProcessing.current = false
|
||||
}, [currentIndex, queue, drawer])
|
||||
|
||||
const processPrevious = useCallback(async () => {
|
||||
if (isProcessing.current || currentIndex <= 0) return
|
||||
|
||||
isProcessing.current = true
|
||||
|
||||
const prevIndex = currentIndex - 1
|
||||
const currentItem = queue[currentIndex]
|
||||
const prevItem = queue[prevIndex]
|
||||
|
||||
// Close current drawer
|
||||
await new Promise((resolve) => {
|
||||
drawer.close(currentItem.id, { transition: 200 })
|
||||
setTimeout(resolve, 200)
|
||||
})
|
||||
|
||||
// Open previous drawer
|
||||
setCurrentIndex(prevIndex)
|
||||
drawer.open(prevItem.id, prevItem.component, prevItem.options)
|
||||
|
||||
isProcessing.current = false
|
||||
}, [currentIndex, queue, drawer])
|
||||
|
||||
const clearQueue = useCallback(() => {
|
||||
setQueue([])
|
||||
setCurrentIndex(-1)
|
||||
drawer.closeAll()
|
||||
}, [drawer])
|
||||
|
||||
const startQueue = useCallback(() => {
|
||||
if (queue.length > 0) {
|
||||
setCurrentIndex(-1)
|
||||
processNext()
|
||||
}
|
||||
}, [queue, processNext])
|
||||
|
||||
return {
|
||||
queue,
|
||||
currentIndex,
|
||||
addToQueue,
|
||||
processNext,
|
||||
processPrevious,
|
||||
clearQueue,
|
||||
startQueue,
|
||||
hasNext: currentIndex < queue.length - 1,
|
||||
hasPrevious: currentIndex > 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for form handling in drawers
|
||||
*/
|
||||
export const useDrawerForm = (drawerId, initialData = {}) => {
|
||||
const drawerState = useDrawerState(drawerId)
|
||||
const [formData, setFormData] = useState(initialData)
|
||||
const [errors, setErrors] = useState({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
|
||||
const updateField = useCallback(
|
||||
(field, value) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
setIsDirty(true)
|
||||
|
||||
// Clear field error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => ({ ...prev, [field]: null }))
|
||||
}
|
||||
},
|
||||
[errors],
|
||||
)
|
||||
|
||||
const setFieldError = useCallback((field, error) => {
|
||||
setErrors((prev) => ({ ...prev, [field]: error }))
|
||||
}, [])
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setErrors({})
|
||||
}, [])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFormData(initialData)
|
||||
setErrors({})
|
||||
setIsDirty(false)
|
||||
setIsSubmitting(false)
|
||||
}, [initialData])
|
||||
|
||||
const openForm = useCallback(
|
||||
(component, options = {}) => {
|
||||
const formOptions = {
|
||||
...options,
|
||||
confirmOnOutsideClick: isDirty,
|
||||
confirmOnOutsideClickText:
|
||||
"You have unsaved changes. Are you sure you want to close?",
|
||||
onClose: () => {
|
||||
if (
|
||||
isDirty &&
|
||||
!window.confirm(
|
||||
"You have unsaved changes. Are you sure you want to close?",
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
reset()
|
||||
options.onClose?.()
|
||||
},
|
||||
}
|
||||
|
||||
drawerState.open(component, formOptions)
|
||||
},
|
||||
[drawerState, isDirty, reset],
|
||||
)
|
||||
|
||||
const submit = useCallback(
|
||||
async (submitFn, options = {}) => {
|
||||
const { validate, onSuccess, onError } = options
|
||||
|
||||
setIsSubmitting(true)
|
||||
clearErrors()
|
||||
|
||||
try {
|
||||
// Run validation if provided
|
||||
if (validate) {
|
||||
const validationErrors = await validate(formData)
|
||||
if (
|
||||
validationErrors &&
|
||||
Object.keys(validationErrors).length > 0
|
||||
) {
|
||||
setErrors(validationErrors)
|
||||
setIsSubmitting(false)
|
||||
return { success: false, errors: validationErrors }
|
||||
}
|
||||
}
|
||||
|
||||
// Submit form
|
||||
const result = await submitFn(formData)
|
||||
|
||||
// Handle success
|
||||
setIsDirty(false)
|
||||
onSuccess?.(result)
|
||||
drawerState.close()
|
||||
|
||||
return { success: true, data: result }
|
||||
} catch (error) {
|
||||
const errorMessage = error.message || "An error occurred"
|
||||
setErrors({ _global: errorMessage })
|
||||
onError?.(error)
|
||||
|
||||
return { success: false, error: errorMessage }
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
},
|
||||
[formData, drawerState, clearErrors],
|
||||
)
|
||||
|
||||
return {
|
||||
...drawerState,
|
||||
formData,
|
||||
errors,
|
||||
isSubmitting,
|
||||
isDirty,
|
||||
updateField,
|
||||
setFieldError,
|
||||
clearErrors,
|
||||
reset,
|
||||
openForm,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for keyboard shortcuts in drawers
|
||||
*/
|
||||
export const useDrawerKeyboard = (shortcuts = {}) => {
|
||||
const drawer = useDrawer()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
// Only handle shortcuts when drawers are open
|
||||
if (drawer.drawersLength() === 0) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
const ctrlKey = event.ctrlKey || event.metaKey
|
||||
const altKey = event.altKey
|
||||
const shiftKey = event.shiftKey
|
||||
|
||||
// Build shortcut key combination
|
||||
let combination = ""
|
||||
if (ctrlKey) combination += "ctrl+"
|
||||
if (altKey) combination += "alt+"
|
||||
if (shiftKey) combination += "shift+"
|
||||
combination += key
|
||||
|
||||
// Execute shortcut if found
|
||||
const shortcut = shortcuts[combination]
|
||||
if (shortcut) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
shortcut(event)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
}
|
||||
}, [shortcuts, drawer])
|
||||
|
||||
return {
|
||||
addShortcut: useCallback(
|
||||
(key, handler) => {
|
||||
shortcuts[key] = handler
|
||||
},
|
||||
[shortcuts],
|
||||
),
|
||||
|
||||
removeShortcut: useCallback(
|
||||
(key) => {
|
||||
delete shortcuts[key]
|
||||
},
|
||||
[shortcuts],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for drawer animations and transitions
|
||||
*/
|
||||
export const useDrawerAnimation = (options = {}) => {
|
||||
const { duration = 300, easing = "ease-out", stagger = 100 } = options
|
||||
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
|
||||
const createVariants = useCallback((position = "left") => {
|
||||
const slideDirection = position === "right" ? 100 : -100
|
||||
|
||||
return {
|
||||
initial: {
|
||||
x: slideDirection,
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
},
|
||||
animate: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
},
|
||||
exit: {
|
||||
x: slideDirection,
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
},
|
||||
}
|
||||
}, [])
|
||||
|
||||
const createTransition = useCallback(
|
||||
(delay = 0) => ({
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 20,
|
||||
duration: duration / 1000,
|
||||
delay: delay / 1000,
|
||||
}),
|
||||
[duration],
|
||||
)
|
||||
|
||||
const staggeredTransition = useCallback(
|
||||
(index = 0) => createTransition(index * stagger),
|
||||
[createTransition, stagger],
|
||||
)
|
||||
|
||||
const animateSequence = useCallback(
|
||||
async (animations) => {
|
||||
setIsAnimating(true)
|
||||
|
||||
for (let i = 0; i < animations.length; i++) {
|
||||
const animation = animations[i]
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
animation()
|
||||
resolve()
|
||||
}, i * stagger)
|
||||
})
|
||||
}
|
||||
|
||||
setTimeout(() => setIsAnimating(false), duration)
|
||||
},
|
||||
[stagger, duration],
|
||||
)
|
||||
|
||||
return {
|
||||
isAnimating,
|
||||
createVariants,
|
||||
createTransition,
|
||||
staggeredTransition,
|
||||
animateSequence,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for drawer persistence (localStorage)
|
||||
*/
|
||||
export const useDrawerPersistence = (key, initialState = {}) => {
|
||||
const [state, setState] = useState(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(`drawer_${key}`)
|
||||
return item ? JSON.parse(item) : initialState
|
||||
} catch {
|
||||
return initialState
|
||||
}
|
||||
})
|
||||
|
||||
const updateState = useCallback(
|
||||
(newState) => {
|
||||
setState(newState)
|
||||
try {
|
||||
localStorage.setItem(`drawer_${key}`, JSON.stringify(newState))
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to save drawer state to localStorage:",
|
||||
error,
|
||||
)
|
||||
}
|
||||
},
|
||||
[key],
|
||||
)
|
||||
|
||||
const clearState = useCallback(() => {
|
||||
setState(initialState)
|
||||
try {
|
||||
localStorage.removeItem(`drawer_${key}`)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to clear drawer state from localStorage:",
|
||||
error,
|
||||
)
|
||||
}
|
||||
}, [key, initialState])
|
||||
|
||||
return [state, updateState, clearState]
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for drawer accessibility features
|
||||
*/
|
||||
export const useDrawerAccessibility = (options = {}) => {
|
||||
const { trapFocus = true, autoFocus = true, restoreFocus = true } = options
|
||||
|
||||
const previousActiveElement = useRef(null)
|
||||
const drawerRef = useRef(null)
|
||||
|
||||
const setupAccessibility = useCallback(() => {
|
||||
// Store the currently focused element
|
||||
if (restoreFocus) {
|
||||
previousActiveElement.current = document.activeElement
|
||||
}
|
||||
|
||||
// Auto focus the drawer
|
||||
if (autoFocus && drawerRef.current) {
|
||||
const firstFocusable = drawerRef.current.querySelector(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Set up focus trap
|
||||
if (trapFocus) {
|
||||
const handleTabKey = (e) => {
|
||||
if (e.key !== "Tab" || !drawerRef.current) return
|
||||
|
||||
const focusableElements = drawerRef.current.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement =
|
||||
focusableElements[focusableElements.length - 1]
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
lastElement.focus()
|
||||
e.preventDefault()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
firstElement.focus()
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleTabKey)
|
||||
return () => document.removeEventListener("keydown", handleTabKey)
|
||||
}
|
||||
}, [trapFocus, autoFocus, restoreFocus])
|
||||
|
||||
const cleanupAccessibility = useCallback(() => {
|
||||
// Restore focus to previous element
|
||||
if (restoreFocus && previousActiveElement.current) {
|
||||
previousActiveElement.current.focus()
|
||||
}
|
||||
}, [restoreFocus])
|
||||
|
||||
return {
|
||||
drawerRef,
|
||||
setupAccessibility,
|
||||
cleanupAccessibility,
|
||||
}
|
||||
}
|
@ -1,153 +1,56 @@
|
||||
import React from "react"
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
createContext,
|
||||
useContext,
|
||||
} from "react"
|
||||
import classnames from "classnames"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
|
||||
import Drawer from "./component"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export class Drawer extends React.Component {
|
||||
options = this.props.options ?? {}
|
||||
// Context for drawer management
|
||||
const DrawerContext = createContext()
|
||||
|
||||
state = {
|
||||
visible: false,
|
||||
// Hook to use drawer context
|
||||
export const useDrawer = () => {
|
||||
const context = useContext(DrawerContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useDrawer must be used within a DrawerProvider")
|
||||
}
|
||||
|
||||
toggleVisibility = (to) => {
|
||||
to = to ?? !this.state.visible
|
||||
|
||||
this.setState({ visible: to })
|
||||
}
|
||||
|
||||
close = async () => {
|
||||
if (typeof this.options.onClose === "function") {
|
||||
this.options.onClose()
|
||||
}
|
||||
|
||||
this.toggleVisibility(false)
|
||||
|
||||
this.props.controller.close(this.props.id, {
|
||||
transition: 150,
|
||||
})
|
||||
}
|
||||
|
||||
handleDone = (...context) => {
|
||||
if (typeof this.options.onDone === "function") {
|
||||
this.options.onDone(this, ...context)
|
||||
}
|
||||
}
|
||||
|
||||
handleFail = (...context) => {
|
||||
if (typeof this.options.onFail === "function") {
|
||||
this.options.onFail(this, ...context)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount = async () => {
|
||||
if (typeof this.props.controller === "undefined") {
|
||||
throw new Error(`Cannot mount an drawer without an controller`)
|
||||
}
|
||||
|
||||
if (typeof this.props.children === "undefined") {
|
||||
throw new Error(`Empty component`)
|
||||
}
|
||||
|
||||
this.toggleVisibility(true)
|
||||
}
|
||||
|
||||
render() {
|
||||
const componentProps = {
|
||||
...this.options.props,
|
||||
close: this.close,
|
||||
handleDone: this.handleDone,
|
||||
handleFail: this.handleFail,
|
||||
}
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={this.props.id}
|
||||
id={this.props.id}
|
||||
className="drawer"
|
||||
style={{
|
||||
...this.options.style,
|
||||
}}
|
||||
animate={{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
}}
|
||||
initial={{
|
||||
x: -100,
|
||||
opacity: 0,
|
||||
}}
|
||||
exit={{
|
||||
x: -100,
|
||||
opacity: 0,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 20,
|
||||
}}
|
||||
>
|
||||
{React.createElement(this.props.children, componentProps)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export default class DrawerController extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
function DrawerController() {
|
||||
const [state, setState] = useState({
|
||||
addresses: {},
|
||||
refs: {},
|
||||
drawers: [],
|
||||
maskVisible: false,
|
||||
})
|
||||
|
||||
this.state = {
|
||||
addresses: {},
|
||||
refs: {},
|
||||
drawers: [],
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
maskVisible: false,
|
||||
}
|
||||
const toggleMaskVisibility = useCallback((to) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
maskVisible: to ?? !prev.maskVisible,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
this.interface = {
|
||||
open: this.open,
|
||||
close: this.close,
|
||||
closeAll: this.closeAll,
|
||||
drawers: () => this.state.drawers,
|
||||
drawersLength: () => this.state.drawers.length,
|
||||
isMaskVisible: () => this.state.maskVisible,
|
||||
}
|
||||
}
|
||||
const handleEscKeyPress = useCallback((event) => {
|
||||
const currentState = stateRef.current
|
||||
|
||||
componentDidMount = () => {
|
||||
app.layout["drawer"] = this.interface
|
||||
|
||||
this.listenEscape()
|
||||
}
|
||||
|
||||
componentWillUnmount = () => {
|
||||
delete app.layout["drawer"]
|
||||
|
||||
this.unlistenEscape()
|
||||
}
|
||||
|
||||
componentWillUpdate = (prevProps, prevState) => {
|
||||
// is mask visible, hide sidebar with `app.layout.sidebar.toggleVisibility(false)`
|
||||
if (app.layout.sidebar) {
|
||||
if (prevState.maskVisible !== this.state.maskVisible) {
|
||||
app.layout.sidebar.toggleVisibility(this.state.maskVisible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listenEscape = () => {
|
||||
document.addEventListener("keydown", this.handleEscKeyPress)
|
||||
}
|
||||
|
||||
unlistenEscape = () => {
|
||||
document.removeEventListener("keydown", this.handleEscKeyPress)
|
||||
}
|
||||
|
||||
handleEscKeyPress = (event) => {
|
||||
if (this.state.drawers.length === 0) {
|
||||
return false
|
||||
if (currentState.drawers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let isEscape = false
|
||||
@ -159,145 +62,223 @@ export default class DrawerController extends React.Component {
|
||||
}
|
||||
|
||||
if (isEscape) {
|
||||
this.closeLastDrawer()
|
||||
closeLastDrawer()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
getLastDrawer = () => {
|
||||
return this.state.drawers[this.state.drawers.length - 1].ref.current
|
||||
}
|
||||
const getLastDrawer = useCallback(() => {
|
||||
const currentState = stateRef.current
|
||||
const lastDrawerId =
|
||||
currentState.drawers[currentState.drawers.length - 1]?.id
|
||||
|
||||
closeLastDrawer = () => {
|
||||
const lastDrawer = this.getLastDrawer()
|
||||
if (!lastDrawerId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (lastDrawer) {
|
||||
if (app.layout.modal && lastDrawer.options.confirmOnOutsideClick) {
|
||||
return {
|
||||
id: lastDrawerId,
|
||||
ref: currentState.refs[lastDrawerId]?.current,
|
||||
options:
|
||||
currentState.drawers[currentState.drawers.length - 1]?.options,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const closeLastDrawer = useCallback(() => {
|
||||
const lastDrawer = getLastDrawer()
|
||||
|
||||
if (lastDrawer && lastDrawer.id) {
|
||||
if (
|
||||
app.layout?.modal &&
|
||||
lastDrawer.options?.confirmOnOutsideClick
|
||||
) {
|
||||
return app.layout.modal.confirm({
|
||||
descriptionText:
|
||||
lastDrawer.options.confirmOnOutsideClickText ??
|
||||
lastDrawer.options.confirmOnOutsideClickText ||
|
||||
"Are you sure you want to close this drawer?",
|
||||
onConfirm: () => {
|
||||
lastDrawer.close()
|
||||
close(lastDrawer.id)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
lastDrawer.close()
|
||||
close(lastDrawer.id)
|
||||
}
|
||||
}
|
||||
}, [getLastDrawer])
|
||||
|
||||
toggleMaskVisibility = async (to) => {
|
||||
this.setState({
|
||||
maskVisible: to ?? !this.state.maskVisible,
|
||||
})
|
||||
}
|
||||
const close = useCallback(
|
||||
async (id, { transition = 0 } = {}) => {
|
||||
const currentState = stateRef.current
|
||||
const index = currentState.addresses[id]
|
||||
const ref = currentState.refs[id]?.current
|
||||
|
||||
open = (id, component, options) => {
|
||||
const refs = this.state.refs ?? {}
|
||||
const drawers = this.state.drawers ?? []
|
||||
const addresses = this.state.addresses ?? {}
|
||||
if (typeof ref === "undefined") {
|
||||
console.warn("This drawer does not exist")
|
||||
return
|
||||
}
|
||||
|
||||
const instance = {
|
||||
id: id,
|
||||
ref: React.createRef(),
|
||||
children: component,
|
||||
options: options,
|
||||
controller: this,
|
||||
}
|
||||
if (currentState.drawers.length === 1) {
|
||||
toggleMaskVisibility(false)
|
||||
}
|
||||
|
||||
if (typeof addresses[id] === "undefined") {
|
||||
drawers.push(<Drawer key={id} {...instance} />)
|
||||
if (transition > 0) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, transition)
|
||||
})
|
||||
}
|
||||
|
||||
addresses[id] = drawers.length - 1
|
||||
refs[id] = instance.ref
|
||||
} else {
|
||||
drawers[addresses[id]] = <Drawer key={id} {...instance} />
|
||||
refs[id] = instance.ref
|
||||
}
|
||||
setState((prev) => {
|
||||
const newDrawers = prev.drawers.filter((_, i) => i !== index)
|
||||
const newAddresses = { ...prev.addresses }
|
||||
const newRefs = { ...prev.refs }
|
||||
|
||||
this.setState({
|
||||
refs,
|
||||
addresses,
|
||||
drawers,
|
||||
})
|
||||
delete newAddresses[id]
|
||||
delete newRefs[id]
|
||||
|
||||
this.toggleMaskVisibility(true)
|
||||
}
|
||||
|
||||
close = async (id, { transition = 0 } = {}) => {
|
||||
let { addresses, drawers, refs } = this.state
|
||||
|
||||
const index = addresses[id]
|
||||
const ref = this.state.refs[id]?.current
|
||||
|
||||
if (typeof ref === "undefined") {
|
||||
return console.warn("This drawer not exists")
|
||||
}
|
||||
|
||||
if (drawers.length === 1) {
|
||||
this.toggleMaskVisibility(false)
|
||||
}
|
||||
|
||||
if (transition > 0) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, transition)
|
||||
return {
|
||||
...prev,
|
||||
refs: newRefs,
|
||||
addresses: newAddresses,
|
||||
drawers: newDrawers,
|
||||
}
|
||||
})
|
||||
},
|
||||
[toggleMaskVisibility],
|
||||
)
|
||||
|
||||
const closeAll = useCallback(() => {
|
||||
const currentState = stateRef.current
|
||||
currentState.drawers.forEach((drawer) => {
|
||||
close(drawer.id)
|
||||
})
|
||||
}, [close])
|
||||
|
||||
// Create controller object
|
||||
const controller = useMemo(
|
||||
() => ({
|
||||
close,
|
||||
closeAll,
|
||||
drawers: () => stateRef.current.drawers,
|
||||
drawersLength: () => stateRef.current.drawers.length,
|
||||
isMaskVisible: () => stateRef.current.maskVisible,
|
||||
}),
|
||||
[close, closeAll],
|
||||
)
|
||||
|
||||
const open = useCallback(
|
||||
(id, component, options = {}) => {
|
||||
setState((prev) => {
|
||||
const { refs, drawers, addresses } = prev
|
||||
|
||||
const newRefs = { ...refs }
|
||||
const newDrawers = [...drawers]
|
||||
const newAddresses = { ...addresses }
|
||||
|
||||
const drawerRef = React.createRef()
|
||||
const instance = {
|
||||
id,
|
||||
children: component,
|
||||
options,
|
||||
controller,
|
||||
}
|
||||
|
||||
if (typeof newAddresses[id] === "undefined") {
|
||||
newDrawers.push({
|
||||
...instance,
|
||||
element: (
|
||||
<Drawer key={id} ref={drawerRef} {...instance} />
|
||||
),
|
||||
})
|
||||
newAddresses[id] = newDrawers.length - 1
|
||||
newRefs[id] = drawerRef
|
||||
} else {
|
||||
newDrawers[newAddresses[id]] = {
|
||||
...instance,
|
||||
element: (
|
||||
<Drawer key={id} ref={drawerRef} {...instance} />
|
||||
),
|
||||
}
|
||||
newRefs[id] = drawerRef
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
refs: newRefs,
|
||||
addresses: newAddresses,
|
||||
drawers: newDrawers,
|
||||
maskVisible: true,
|
||||
}
|
||||
})
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
// Complete interface with open method
|
||||
const interface_ = useMemo(
|
||||
() => ({
|
||||
...controller,
|
||||
open,
|
||||
}),
|
||||
[controller, open],
|
||||
)
|
||||
|
||||
// Setup effects
|
||||
useEffect(() => {
|
||||
if (app.layout) {
|
||||
app.layout["drawer"] = interface_
|
||||
}
|
||||
|
||||
if (typeof drawers[index] !== "undefined") {
|
||||
drawers = drawers.filter((value, i) => i !== index)
|
||||
return () => {
|
||||
if (app.layout) {
|
||||
delete app.layout["drawer"]
|
||||
}
|
||||
}
|
||||
}, [interface_])
|
||||
|
||||
delete addresses[id]
|
||||
delete refs[id]
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleEscKeyPress)
|
||||
|
||||
this.setState({
|
||||
refs,
|
||||
addresses,
|
||||
drawers,
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscKeyPress)
|
||||
}
|
||||
}, [handleEscKeyPress])
|
||||
|
||||
closeAll = () => {
|
||||
this.state.drawers.forEach((drawer) => {
|
||||
drawer.ref.current.close()
|
||||
})
|
||||
}
|
||||
// Handle sidebar visibility based on mask visibility
|
||||
useEffect(() => {
|
||||
if (app.layout?.sidebar) {
|
||||
app.layout.sidebar.toggleVisibility(!state.maskVisible)
|
||||
}
|
||||
}, [state.maskVisible])
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{this.state.maskVisible && (
|
||||
<motion.div
|
||||
className="drawers-mask"
|
||||
onClick={() => this.closeLastDrawer()}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<DrawerContext.Provider value={interface_}>
|
||||
<AnimatePresence>
|
||||
{state.maskVisible && (
|
||||
<motion.div
|
||||
className="drawers-mask"
|
||||
onClick={closeLastDrawer}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 20,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
className={classnames("drawers-wrapper", {
|
||||
["hidden"]: !state.drawers.length,
|
||||
})}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{state.drawers.map((drawer) => drawer.element)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
className={classnames("drawers-wrapper", {
|
||||
["hidden"]: !this.state.drawers.length,
|
||||
})}
|
||||
>
|
||||
{this.state.drawers}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</DrawerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default DrawerController
|
||||
|
@ -1,87 +1,206 @@
|
||||
@import "@styles/vars.less";
|
||||
|
||||
.drawers-wrapper {
|
||||
position: absolute;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: @sidebar_padding;
|
||||
margin-left: calc(@sidebar_padding * 2);
|
||||
height: 100dvh;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
z-index: 1200;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
padding: @sidebar_padding;
|
||||
|
||||
margin-left: calc(@sidebar_padding * 2);
|
||||
|
||||
height: 100dvh;
|
||||
height: 100vh;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.drawers-mask {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
z-index: 1100;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(4px);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1100;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: relative;
|
||||
position: relative;
|
||||
z-index: 1300;
|
||||
|
||||
z-index: 1300;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: auto;
|
||||
|
||||
width: fit-content;
|
||||
min-width: 320px;
|
||||
height: 100%;
|
||||
width: fit-content;
|
||||
min-width: 320px;
|
||||
max-width: 90vw;
|
||||
height: 100%;
|
||||
|
||||
padding: 20px;
|
||||
background-color: var(--background-color-accent);
|
||||
border-radius: @sidebar_borderRadius;
|
||||
box-shadow: @card-shadow;
|
||||
border: 1px solid var(--sidebar-background-color);
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
overflow: hidden;
|
||||
|
||||
border-radius: @sidebar_borderRadius;
|
||||
box-shadow: @card-shadow;
|
||||
border: 1px solid var(--sidebar-background-color);
|
||||
&.drawer-left {
|
||||
left: 0;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: overlay;
|
||||
&.drawer-right {
|
||||
right: 0;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
flex-shrink: 0;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--background-color);
|
||||
|
||||
.drawer-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
.drawer-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.drawer-custom-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.drawer-close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--text-color);
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
|
||||
color: var(--text-color);
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-thumb-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--scrollbar-thumb-hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.drawers-wrapper {
|
||||
margin-left: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
min-width: 280px;
|
||||
max-width: calc(100vw - 20px);
|
||||
|
||||
.drawer-header {
|
||||
padding: 12px 16px;
|
||||
|
||||
.drawer-header-content {
|
||||
.drawer-title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
.drawer-close-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm dialog styles
|
||||
.drawer_close_confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
|
||||
gap: 10px;
|
||||
.drawer_close_confirm_content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
|
||||
.drawer_close_confirm_content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.drawer_close_confirm_actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
gap: 10px;
|
||||
}
|
||||
.drawer_close_confirm_actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
@ -5,7 +5,7 @@ import useLayoutInterface from "@hooks/useLayoutInterface"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
export default (props) => {
|
||||
const HeaderBar = (props) => {
|
||||
const [render, setRender] = React.useState(null)
|
||||
|
||||
useLayoutInterface("header", {
|
||||
@ -33,15 +33,23 @@ export default (props) => {
|
||||
<AnimatePresence>
|
||||
{render && (
|
||||
<motion.div
|
||||
layoutRoot
|
||||
className="page_header_wrapper"
|
||||
animate={{
|
||||
y: 0,
|
||||
}}
|
||||
initial={{
|
||||
y: -100,
|
||||
width: "100%",
|
||||
}}
|
||||
animate={{
|
||||
y: 0,
|
||||
position: "sticky",
|
||||
width: "100%",
|
||||
}}
|
||||
exit={{
|
||||
y: -100,
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
@ -61,3 +69,5 @@ export default (props) => {
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderBar
|
||||
|
@ -17,58 +17,63 @@ import TopBar from "@layouts/components/@mobile/topBar"
|
||||
import BackgroundDecorator from "@components/BackgroundDecorator"
|
||||
|
||||
const DesktopLayout = (props) => {
|
||||
return <>
|
||||
<BackgroundDecorator />
|
||||
<Modals />
|
||||
<DraggableDrawerController />
|
||||
return (
|
||||
<>
|
||||
<BackgroundDecorator />
|
||||
<Modals />
|
||||
<DraggableDrawerController />
|
||||
|
||||
<Layout id="app_layout" className="app_layout">
|
||||
<Sidebar />
|
||||
<Layout id="app_layout" className="app_layout">
|
||||
<Sidebar user={props.user} />
|
||||
|
||||
<Layout.Content
|
||||
id="content_layout"
|
||||
className={classnames(
|
||||
...props.contentClassnames ?? [],
|
||||
"content_layout",
|
||||
"fade-transverse-active",
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<Layout.Content
|
||||
id="content_layout"
|
||||
className={classnames(
|
||||
...(props.contentClassnames ?? []),
|
||||
"content_layout",
|
||||
"fade-transverse-active",
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
|
||||
{
|
||||
props.children && React.cloneElement(props.children, props)
|
||||
}
|
||||
</Layout.Content>
|
||||
{props.children &&
|
||||
React.cloneElement(props.children, props)}
|
||||
</Layout.Content>
|
||||
|
||||
<ToolsBar />
|
||||
</Layout>
|
||||
<ToolsBar />
|
||||
</Layout>
|
||||
|
||||
<BetaBanner />
|
||||
</>
|
||||
<BetaBanner />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MobileLayout = (props) => {
|
||||
return <Layout id="app_layout" className="app_layout">
|
||||
<DraggableDrawerController />
|
||||
<TopBar />
|
||||
return (
|
||||
<Layout id="app_layout" className="app_layout">
|
||||
<DraggableDrawerController />
|
||||
<TopBar />
|
||||
|
||||
<Layout.Content
|
||||
id="content_layout"
|
||||
className={classnames(
|
||||
...props.layoutPageModesClassnames ?? [],
|
||||
"content_layout",
|
||||
"fade-transverse-active",
|
||||
)}
|
||||
>
|
||||
{
|
||||
props.children && React.cloneElement(props.children, props)
|
||||
}
|
||||
</Layout.Content>
|
||||
<Layout.Content
|
||||
id="content_layout"
|
||||
className={classnames(
|
||||
...(props.layoutPageModesClassnames ?? []),
|
||||
"content_layout",
|
||||
"fade-transverse-active",
|
||||
)}
|
||||
>
|
||||
{props.children && React.cloneElement(props.children, props)}
|
||||
</Layout.Content>
|
||||
|
||||
<BottomBar />
|
||||
</Layout>
|
||||
<BottomBar />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default (props) => {
|
||||
return window.app.isMobile ? <MobileLayout {...props} /> : <DesktopLayout {...props} />
|
||||
return window.app.isMobile ? (
|
||||
<MobileLayout {...props} />
|
||||
) : (
|
||||
<DesktopLayout {...props} />
|
||||
)
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import Hls from "hls.js"
|
||||
|
||||
const exampleData = {
|
||||
video: "https://im-fa.manifest.tidal.com/1/manifests/CAESCTE5Njg2MTQ0NCIWd05QUkh1YTIyOGRXTUVUdmFxbThQdyIWZE05ZHNYTFNkTEhaODdmTUxQMDhGQSIWS0dfYTZubHUtcTUydVZMenRyOTJwQSIWLWU1NHRpanJlNzZhSjdMcXVoQ05idyIWenRCWnZEYmpia1hvNS14UUowWFl1USIWdFRHY20ycFNpVTktaHBtVDlzUlNvdyIWdVJDMlNqMFJQYWVMSnN6NWRhRXZtdyIWZnNYUWZpNk01LUdpeUV3dE9JNTZ2dygBMAJQAQ.m3u8?token=1738270941~MjEyMTc0MTk0NTlmNjdiY2RkNjljYzc0NzU1NGRmZDcxMGJhNDI2Mg==",
|
||||
audio: "https://sp-pr-fa.audio.tidal.com/mediatracks/CAEaKwgDEidmMmE5YjEyYTQ5ZTQ4YWFkZDdhOTY0YzBmZTdhZTY1ZV82MS5tcDQ/0.flac?token=1738270937~Y2ViYjZiNmYyZmVjN2JhNmYzN2ViMWEzOTcwNzQ3NDdkNzA5YzhhZg=="
|
||||
}
|
||||
|
||||
function AudioSyncApp() {
|
||||
const videoRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
const [worker, setWorker] = useState(null);
|
||||
const [startTime, setStartTime] = useState(null);
|
||||
const audioCtxRef = useRef(null);
|
||||
const hlsRef = useRef(null);
|
||||
|
||||
// Configurar HLS para el video
|
||||
useEffect(() => {
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls({ enableWorker: false, xhrSetup: (xhr) => xhr.withCredentials = false });
|
||||
hlsRef.current = hls;
|
||||
hls.loadSource(exampleData.video);
|
||||
hls.attachMedia(videoRef.current);
|
||||
} else if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
videoRef.current.src = exampleData.video;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) hlsRef.current.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Inicializar Web Audio y Worker
|
||||
useEffect(() => {
|
||||
audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const newWorker = new Worker(new URL("./worker.js", import.meta.url));
|
||||
newWorker.onmessage = (event) => {
|
||||
setStartTime(event.data.offset);
|
||||
};
|
||||
setWorker(newWorker);
|
||||
|
||||
return () => newWorker.terminate();
|
||||
}, []);
|
||||
|
||||
// Manejar la sincronización
|
||||
const handleSync = async () => {
|
||||
try {
|
||||
// 1. Obtener buffers de audio
|
||||
const [videoBuffer, audioBuffer] = await Promise.all([
|
||||
fetch(exampleData.video, { mode: "cors" }).then(r => r.arrayBuffer()),
|
||||
fetch(exampleData.audio, { mode: "cors" }).then(r => r.arrayBuffer())
|
||||
]);
|
||||
|
||||
// 2. Decodificar
|
||||
const [videoAudio, songAudio] = await Promise.all([
|
||||
audioCtxRef.current.decodeAudioData(videoBuffer),
|
||||
audioCtxRef.current.decodeAudioData(audioBuffer)
|
||||
]);
|
||||
|
||||
// 3. Enviar al Worker
|
||||
worker.postMessage(
|
||||
{ videoBuffer: videoAudio, audioBuffer: songAudio },
|
||||
[videoAudio, songAudio]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error de decodificación:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
crossOrigin="anonymous"
|
||||
width="600"
|
||||
/>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
controls
|
||||
crossOrigin="anonymous"
|
||||
src={exampleData.audio}
|
||||
/>
|
||||
<button onClick={handleSync}>Sincronizar</button>
|
||||
{startTime !== null && (
|
||||
<p>Offset de sincronización: {startTime.toFixed(2)} segundos</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AudioSyncApp;
|
@ -1,70 +0,0 @@
|
||||
self.onmessage = async (event) => {
|
||||
const { videoBuffer, audioBuffer } = event.data;
|
||||
const SAMPLE_RATE = 44100;
|
||||
|
||||
// Extraer energía en rango de frecuencias
|
||||
const getEnergy = (buffer, freqRange) => {
|
||||
const offlineCtx = new OfflineAudioContext(1, buffer.length, SAMPLE_RATE);
|
||||
const source = offlineCtx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
|
||||
const analyser = offlineCtx.createAnalyser();
|
||||
analyser.fftSize = 4096;
|
||||
source.connect(analyser);
|
||||
analyser.connect(offlineCtx.destination);
|
||||
source.start();
|
||||
|
||||
return offlineCtx.startRendering().then(() => {
|
||||
const data = new Float32Array(analyser.frequencyBinCount);
|
||||
analyser.getFloatFrequencyData(data);
|
||||
|
||||
const startBin = Math.floor(freqRange[0] * analyser.fftSize / SAMPLE_RATE);
|
||||
const endBin = Math.floor(freqRange[1] * analyser.fftSize / SAMPLE_RATE);
|
||||
return data.slice(startBin, endBin);
|
||||
});
|
||||
};
|
||||
|
||||
// Cross-correlación optimizada
|
||||
const crossCorrelate = (videoFeatures, audioFeatures) => {
|
||||
let maxCorr = -Infinity;
|
||||
let bestOffset = 0;
|
||||
|
||||
for (let i = 0; i < videoFeatures.length - audioFeatures.length; i++) {
|
||||
let corr = 0;
|
||||
for (let j = 0; j < audioFeatures.length; j++) {
|
||||
corr += videoFeatures[i + j] * audioFeatures[j];
|
||||
}
|
||||
if (corr > maxCorr) {
|
||||
maxCorr = corr;
|
||||
bestOffset = i;
|
||||
}
|
||||
}
|
||||
return bestOffset;
|
||||
};
|
||||
|
||||
// Procesar características
|
||||
try {
|
||||
const [videoBass, audioBass] = await Promise.all([
|
||||
getEnergy(videoBuffer, [60, 250]), // Bajos
|
||||
getEnergy(audioBuffer, [60, 250])
|
||||
]);
|
||||
|
||||
const [videoVoice, audioVoice] = await Promise.all([
|
||||
getEnergy(videoBuffer, [300, 3400]), // Voces
|
||||
getEnergy(audioBuffer, [300, 3400])
|
||||
]);
|
||||
|
||||
// Combinar características (peso dinámico)
|
||||
const isElectronic = audioVoice.reduce((a, b) => a + b) < audioBass.reduce((a, b) => a + b);
|
||||
const weight = isElectronic ? 0.8 : 0.4;
|
||||
|
||||
const videoFeatures = videoBass.map((v, i) => weight * v + (1 - weight) * videoVoice[i]);
|
||||
const audioFeatures = audioBass.map((v, i) => weight * v + (1 - weight) * audioVoice[i]);
|
||||
|
||||
// Calcular offset
|
||||
const offset = crossCorrelate(videoFeatures, audioFeatures);
|
||||
self.postMessage({ offset: offset / SAMPLE_RATE });
|
||||
} catch (error) {
|
||||
self.postMessage({ error: "Error en el procesamiento" });
|
||||
}
|
||||
};
|
@ -1,47 +0,0 @@
|
||||
import TrackManifest from "@cores/player/classes/TrackManifest"
|
||||
|
||||
const D_Manifest = () => {
|
||||
const [manifest, setManifest] = React.useState(null)
|
||||
|
||||
function selectLocalFile() {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "audio/*"
|
||||
input.onchange = (e) => {
|
||||
loadManifest(e.target.files[0])
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
async function loadManifest(file) {
|
||||
let track = new TrackManifest({ file: file })
|
||||
|
||||
await track.initialize()
|
||||
|
||||
console.log(track)
|
||||
|
||||
setManifest(track)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-column gap-10">
|
||||
<p>Select a local file to view & create a track manifest</p>
|
||||
|
||||
<button onClick={selectLocalFile}>Select</button>
|
||||
|
||||
{manifest?.cover && (
|
||||
<img
|
||||
src={manifest.cover}
|
||||
alt="Cover"
|
||||
style={{ width: "100px", height: "100px" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<code style={{ whiteSpace: "break-spaces", width: "300px" }}>
|
||||
{JSON.stringify(manifest)}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default D_Manifest
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user